run402 4.26.0 → 4.28.0

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.
@@ -126,6 +126,52 @@ export function clearActiveProjectId(projectId, path, scope = {}) {
126
126
  saveProfileState(state, p);
127
127
  });
128
128
  }
129
+ // ---------------------------------------------------------------------------
130
+ // Selected organization (add-cli-current-org, design D3).
131
+ //
132
+ // Scoped exactly like the active project id — by api_base, profile, and
133
+ // principal — and stored in the SAME per-profile state.json, never in the
134
+ // base-level config.json beside `active_wallet`. The chain is
135
+ // wallet -> principal -> memberships, so a globally-selected org survives
136
+ // `wallets use other` and then either 403s or, worse, silently resolves to a
137
+ // valid-but-wrong org when both principals are members. Per-profile makes the
138
+ // failure mode "this profile has no current org", which the ORG_REQUIRED
139
+ // envelope already knows how to explain.
140
+ //
141
+ // There is deliberately no top-level `active_org_id` mirror: the project one
142
+ // exists only to carry pre-scoping installs forward, and this key is new.
143
+ // ---------------------------------------------------------------------------
144
+ export function getActiveOrgId(path, scope = {}) {
145
+ const state = loadProfileState(path);
146
+ return state.active_orgs?.[activeProjectScopeKey(scope)]?.org_id;
147
+ }
148
+ export function setActiveOrgId(orgId, path, scope = {}) {
149
+ const p = path ?? getProfileStatePath();
150
+ withFileLock(p, () => {
151
+ const state = loadProfileState(p);
152
+ const key = activeProjectScopeKey(scope);
153
+ const resolved = defaultActiveProjectScope(scope);
154
+ state.active_orgs = state.active_orgs ?? {};
155
+ state.active_orgs[key] = {
156
+ org_id: orgId,
157
+ api_base: resolved.api_base,
158
+ principal: resolved.principal ?? null,
159
+ profile: resolved.profile,
160
+ updated_at: new Date().toISOString(),
161
+ };
162
+ saveProfileState(state, p);
163
+ });
164
+ }
165
+ export function clearActiveOrgId(path, scope = {}) {
166
+ const p = path ?? getProfileStatePath();
167
+ withFileLock(p, () => {
168
+ const state = loadProfileState(p);
169
+ const key = activeProjectScopeKey(scope);
170
+ if (state.active_orgs?.[key])
171
+ delete state.active_orgs[key];
172
+ saveProfileState(state, p);
173
+ });
174
+ }
129
175
  export function recordMigration(marker, value, path) {
130
176
  const p = path ?? getProfileStatePath();
131
177
  withFileLock(p, () => {
package/lib/claims.mjs CHANGED
@@ -51,7 +51,9 @@ Advisory, always:
51
51
  Room addressing:
52
52
  Default room of the active project with no flags; --project <id> for another
53
53
  project; --org <org_id> --room <key> (or RUN402_ROOM=<org_id>/<key>) for a
54
- named org room.
54
+ named org room. --room <key> alone works when the org resolves on its own
55
+ (--org / RUN402_ORG / the 'org' key of the nearest .run402.json / 'org use'),
56
+ and {"org":"...","room":"..."} in .run402.json binds a whole checkout.
55
57
 
56
58
  Options:
57
59
  --mode <m> exclusive (default) — one worker; shared — parallel-safe.
@@ -199,6 +199,11 @@ export const COMMAND_MANIFEST = [
199
199
  { path: ["org", "payout-wallet"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1", "--wallet", "0x1111111111111111111111111111111111111111"] },
200
200
  { path: ["org", "whoami"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
201
201
  { path: ["org", "audit"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1"] },
202
+ { path: ["org", "use"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["11111111-2222-3333-4444-555555555555"] },
203
+ { path: ["org", "current"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
204
+ { path: ["org", "clear"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
205
+ { path: ["org", "bind"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["--org", "11111111-2222-3333-4444-555555555555"] },
206
+ { path: ["org", "unbind"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
202
207
  { path: ["org", "member", "list"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1"] },
203
208
  { path: ["org", "member", "add"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1", "--wallet", "0x1111111111111111111111111111111111111111"] },
204
209
  { path: ["org", "member", "role"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1", "--principal", "prn_gate1", "--role", "viewer"] },
package/lib/init.mjs CHANGED
@@ -627,6 +627,29 @@ export async function run(args = []) {
627
627
  }
628
628
  }
629
629
 
630
+ // 5b. The org. `init` already materializes it (the tier read above
631
+ // authenticates, which provisions the wallet's org-of-one), so NOT reporting
632
+ // it forced every agent that wanted to coordinate to go find it with a second
633
+ // command and copy a UUID by hand. It is an identifier, not a credential.
634
+ try {
635
+ const orgs = await getSdk().orgs.list();
636
+ const rows = Array.isArray(orgs) ? orgs : (orgs?.orgs ?? []);
637
+ summary.orgs = rows.map((o) => ({
638
+ org_id: o.org_id,
639
+ display_name: o.display_name ?? null,
640
+ role: o.role ?? null,
641
+ }));
642
+ if (rows.length === 1) {
643
+ line("Org", `${rows[0].org_id}${rows[0].display_name ? ` (${rows[0].display_name})` : ""}`);
644
+ } else if (rows.length > 1) {
645
+ line("Org", `${rows.length} organizations — run402 org list`);
646
+ }
647
+ } catch {
648
+ // Best-effort, exactly like the billing read: a listing failure must never
649
+ // fail setup.
650
+ summary.orgs = null;
651
+ }
652
+
630
653
  // 6. Next step — canonical typed action(s); `next_step` is the back-compat
631
654
  // string mirror of the first action's command (one spelling, surface-wide).
632
655
  write("");
@@ -1,29 +1,310 @@
1
1
  /**
2
2
  * Shared organization resolution for org-scoped CLI families
3
- * (attention-architecture Wave E — one resolver, not one per family).
3
+ * (attention-architecture Wave E — one resolver, not one per family;
4
+ * extended by add-cli-current-org with the local-context classes).
4
5
  *
5
- * Precedence: explicit `--org` flag `RUN402_ORG` env the active project's
6
- * owning org (the zero-config checkout path, one SDK lookup). Every org-scoped
7
- * family (`escalations`, and whatever ships next) resolves through here so a
8
- * checkout needs no flags and the precedence cannot drift per family.
6
+ * The org is the ownership root it owns projects, holds memberships, and is
7
+ * the scope for rooms, escalations, members, grants, and audit. This is the one
8
+ * place org precedence is expressed.
9
9
  *
10
- * `rooms`/`claims` predate this and resolve a PAIR ({orgId, roomKey}) with
11
- * their own `RUN402_ROOM` env form see `rooms-context.mjs`; forcing them
12
- * through a single-value resolver would contort both. One resolver per SHAPE.
10
+ * Precedence is four INTENT CLASSES, highest first, and inside each class an
11
+ * organization named directly outranks one derived from a project named in that
12
+ * same class:
13
+ *
14
+ * 1. flag --org <org_id> else --project <id> -> its org
15
+ * 2. environment RUN402_ORG, else the org else RUN402_PROJECT_ID -> its org
16
+ * half of RUN402_ROOM
17
+ * 3. binding the `org` key of the nearest .run402(.local).json
18
+ * 4. profile state the profile's selected org else its active project -> org
19
+ *
20
+ * The class rule is what makes `--project X` behave: naming a project IS naming
21
+ * its organization, so a stale profile selection must not outrank it. Classes 3
22
+ * and 4 are what let an org that owns NO project be addressed at all — every
23
+ * earlier rung needed an argument, a variable, or a deployed project.
24
+ *
25
+ * `rooms`/`claims` resolve a PAIR ({orgId, roomKey}) with their own
26
+ * `RUN402_ROOM` env form — see `rooms-context.mjs`. They are not forced through
27
+ * this single-value resolver: the pair keeps its own shape and its own
28
+ * precedence, and only the ORG HALF of its fallback delegates here. One
29
+ * resolver per shape, composed for the half they share.
30
+ *
31
+ * Two things this deliberately does NOT do:
32
+ * - Infer the org from the caller's memberships, at any count. Membership is
33
+ * server state that changes without the caller acting, so a heuristic that is
34
+ * right today silently changes meaning the day they are invited elsewhere.
35
+ * - Validate membership locally. A well-formed id goes to the server and the
36
+ * server's answer is surfaced as returned — never rewritten into a local
37
+ * not-found (that would invent the existence oracle the gateway refuses to
38
+ * provide) and never retried against a lower class (that would act on a
39
+ * different organization than the caller named).
13
40
  */
14
41
  import { getSdk } from "./sdk.mjs";
15
42
  import { flagValue } from "./argparse.mjs";
43
+ import { findBindingKey } from "./wallet-context.mjs";
44
+ import { fail } from "./sdk-errors.mjs";
45
+ import { nextAction } from "./next-actions.mjs";
46
+ import {
47
+ getActiveOrgId as coreGetActiveOrgId,
48
+ setActiveOrgId as coreSetActiveOrgId,
49
+ clearActiveOrgId as coreClearActiveOrgId,
50
+ } from "../core-dist/profile-state.js";
51
+ import { getActiveProjectId } from "../core-dist/keystore.js";
16
52
  import { getProject, resolveProjectId, updateProject } from "./config.mjs";
17
53
 
18
- /** Resolve the addressed organization from normalized argv (one SDK lookup at most). */
19
- export async function resolveOrgId(a) {
20
- const explicit = flagValue(a, "--org");
21
- if (explicit) return explicit;
22
- const envOrg = (process.env.RUN402_ORG ?? "").trim();
23
- if (envOrg) return envOrg;
24
- const projectId = resolveProjectId(flagValue(a, "--project"));
25
- const scoped = await getSdk().rooms.forProject(projectId);
26
- return scoped.orgId;
54
+ export const ORG_ENV = "RUN402_ORG";
55
+ export const ROOM_ENV = "RUN402_ROOM";
56
+ export const PROJECT_ENV = "RUN402_PROJECT_ID";
57
+
58
+ /** Command groups that must stay usable while selection is ambiguous. */
59
+ const CONFLICT_EXEMPT = new Set(["org", "wallets", "doctor"]);
60
+
61
+ /** `org_id` is a UUID at every API boundary (`uuidParam` on every route). */
62
+ const ORG_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
63
+
64
+ const trimmed = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
65
+
66
+ /**
67
+ * Shape-validate an organization id supplied by a local source. Membership is
68
+ * NEVER checked here — only that the value could be an org id at all.
69
+ */
70
+ function assertOrgIdShape(orgId, origin) {
71
+ if (ORG_ID_RE.test(orgId)) return orgId;
72
+ fail({
73
+ code: "BAD_ORG_ID",
74
+ message: `Invalid organization id ${JSON.stringify(orgId)} (from ${origin}).`,
75
+ hint: "An org_id is a UUID. Run 'run402 org list' to see the organizations you belong to.",
76
+ details: { org_id: orgId, origin },
77
+ next_actions: [listOrgsAction()],
78
+ });
79
+ }
80
+
81
+ /** The org half of `RUN402_ROOM=<org_id>/<room_key>`, or null. */
82
+ function orgFromRoomEnv(env) {
83
+ const raw = trimmed(env[ROOM_ENV]);
84
+ if (!raw) return null;
85
+ const slash = raw.indexOf("/");
86
+ if (slash <= 0 || slash === raw.length - 1) return null; // rooms-context reports the malformed form
87
+ return raw.slice(0, slash);
88
+ }
89
+
90
+ function listOrgsAction() {
91
+ return nextAction("edit_request", {
92
+ command: "run402 org list",
93
+ why: "List the organizations this wallet belongs to.",
94
+ });
95
+ }
96
+
97
+ function orgRequiredActions() {
98
+ return [
99
+ nextAction("edit_request", {
100
+ command: "run402 org use <org_id>",
101
+ why: "Select a current organization for this wallet profile.",
102
+ }),
103
+ listOrgsAction(),
104
+ nextAction("edit_request", {
105
+ command: "run402 <command> --org <org_id>",
106
+ why: "Name the organization on this one call.",
107
+ }),
108
+ nextAction("edit_request", {
109
+ command: "export RUN402_ORG=<org_id>",
110
+ why: "Name the organization for every call in this shell (harness wiring).",
111
+ }),
112
+ nextAction("edit_request", {
113
+ command: `echo '{"org":"<org_id>"}' > .run402.json`,
114
+ why: "Bind this checkout to an organization so every agent in it inherits the same one.",
115
+ }),
116
+ nextAction("edit_request", {
117
+ command: "run402 projects use <project_id>",
118
+ why: "Select a project; its owning organization becomes the current one.",
119
+ }),
120
+ ];
121
+ }
122
+
123
+ /** Resolve a project's owning org (one GET). Returns null when unresolvable. */
124
+ async function orgOfProject(projectId, { required }) {
125
+ // Upstream's per-project cache answers the same question for free when it is
126
+ // warm. It is NOT used as the whole implementation: it returns null on
127
+ // failure, and an explicitly named project must be able to HARD STOP rather
128
+ // than fall through to a lower class.
129
+ const cached = getProject(projectId)?.org_id;
130
+ if (typeof cached === "string" && cached.length > 0) return cached;
131
+ try {
132
+ const scoped = await getSdk().rooms.forProject(projectId);
133
+ return scoped.orgId;
134
+ } catch (err) {
135
+ // An EXPLICITLY named project that cannot be resolved is a hard stop — the
136
+ // caller named it, so falling to a lower class would act on a different
137
+ // organization than the one they asked for. An implicitly selected project
138
+ // (profile state) may simply be stale; skip the class.
139
+ if (!required) return null;
140
+ throw err;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Resolve the organization an org-scoped command acts on.
146
+ *
147
+ * Accepts either normalized argv (the Wave E call shape, still used by
148
+ * `escalations`) or an options object for callers that already hold values.
149
+ *
150
+ * @param {string[]|object} input normalized argv, or { org, project }
151
+ * @param {object} [opts]
152
+ * @param {string} [opts.cmd] command group, for the conflict exemption
153
+ * @param {object} [opts.env] environment (injectable for tests)
154
+ * @param {string} [opts.cwd] directory to walk up from (injectable)
155
+ * @param {boolean} [opts.optional] return null instead of failing when absent
156
+ * @returns {Promise<{orgId: string, source: string, sourceDetail: string}|null>}
157
+ */
158
+ export async function resolveOrg(input = {}, opts = {}) {
159
+ const { cmd, env = process.env, cwd = process.cwd(), optional = false } = opts;
160
+ const org = Array.isArray(input) ? flagValue(input, "--org") : input.org;
161
+ const project = Array.isArray(input) ? flagValue(input, "--project") : input.project;
162
+
163
+ // --- Class 1: flag -------------------------------------------------------
164
+ const orgFlag = trimmed(org);
165
+ if (orgFlag) {
166
+ return { orgId: assertOrgIdShape(orgFlag, "--org"), source: "flag", sourceDetail: "--org" };
167
+ }
168
+ const projectFlag = trimmed(project);
169
+ if (projectFlag) {
170
+ const orgId = await orgOfProject(projectFlag, { required: true });
171
+ if (orgId) return { orgId, source: "flag", sourceDetail: "--project" };
172
+ }
173
+
174
+ // --- Class 2 and 3: environment vs binding (the conflict pair) ------------
175
+ const bindingHit = findBindingKey(cwd, "org");
176
+ const bindingOrg = bindingHit ? assertOrgIdShape(bindingHit.value, bindingHit.file) : null;
177
+
178
+ const envDirect = trimmed(env[ORG_ENV]);
179
+ const envRoomOrg = envDirect ? null : orgFromRoomEnv(env);
180
+ const envProject = envDirect || envRoomOrg ? null : trimmed(env[PROJECT_ENV]);
181
+
182
+ let envOrg = null;
183
+ let envDetail = null;
184
+ if (envDirect) {
185
+ envOrg = assertOrgIdShape(envDirect, ORG_ENV);
186
+ envDetail = ORG_ENV;
187
+ } else if (envRoomOrg) {
188
+ envOrg = assertOrgIdShape(envRoomOrg, ROOM_ENV);
189
+ envDetail = ROOM_ENV;
190
+ } else if (envProject && bindingOrg) {
191
+ // Only worth a lookup when a binding exists to disagree with; otherwise the
192
+ // env class wins uncontested and the lookup happens once, below.
193
+ envOrg = await orgOfProject(envProject, { required: false });
194
+ envDetail = PROJECT_ENV;
195
+ }
196
+
197
+ if (envOrg && bindingOrg && envOrg !== bindingOrg && !CONFLICT_EXEMPT.has(cmd)) {
198
+ // The wallet tier's rule, scoped to the same pair. A binding deliberately
199
+ // declared by a checkout outranks the profile's selection SILENTLY — that
200
+ // is what a binding is for — but an ambient env var disagreeing with a
201
+ // committed file is exactly the surprise worth stopping on.
202
+ fail({
203
+ code: "AMBIGUOUS_ORG",
204
+ message: `Ambiguous organization: ${envDetail}=${envOrg} but ${bindingHit.file} binds ${bindingOrg}.`,
205
+ hint: `Resolve with one of: pass --org <org_id>, unset ${envDetail}, or edit the binding file.`,
206
+ details: {
207
+ candidates: [
208
+ { org_id: envOrg, source: "env", source_detail: envDetail },
209
+ { org_id: bindingOrg, source: "binding", source_detail: bindingHit.file },
210
+ ],
211
+ },
212
+ next_actions: [
213
+ nextAction("edit_request", {
214
+ command: "run402 <command> --org <org_id>",
215
+ why: "The flag resolves the conflict for this call.",
216
+ }),
217
+ ],
218
+ });
219
+ }
220
+
221
+ if (envOrg) return { orgId: envOrg, source: "env", sourceDetail: envDetail };
222
+ if (envProject) {
223
+ const orgId = await orgOfProject(envProject, { required: false });
224
+ if (orgId) return { orgId, source: "env", sourceDetail: PROJECT_ENV };
225
+ }
226
+ if (bindingOrg) return { orgId: bindingOrg, source: "binding", sourceDetail: bindingHit.file };
227
+
228
+ // --- Class 4: profile state ----------------------------------------------
229
+ const selected = trimmed(coreGetActiveOrgId());
230
+ if (selected) {
231
+ return { orgId: assertOrgIdShape(selected, "org use"), source: "profile", sourceDetail: "org use" };
232
+ }
233
+ const activeProject = trimmed(getActiveProjectId());
234
+ if (activeProject) {
235
+ const orgId = await orgOfProject(activeProject, { required: false });
236
+ if (orgId) return { orgId, source: "profile", sourceDetail: "projects use" };
237
+ }
238
+
239
+ if (optional) return null;
240
+ fail({
241
+ code: "ORG_REQUIRED",
242
+ message: "No organization specified and no current organization set.",
243
+ hint: `Pass --org <org_id>, set ${ORG_ENV}, bind this directory in .run402.json, or run: run402 org use <org_id>`,
244
+ next_actions: orgRequiredActions(),
245
+ });
246
+ return null; // unreachable — fail() exits
247
+ }
248
+
249
+ /**
250
+ * Resolve the addressed organization id from normalized argv.
251
+ *
252
+ * The Wave E signature, preserved: callers that only need the id keep passing
253
+ * argv and getting a string back. Use {@link resolveOrg} when the provenance
254
+ * pair is wanted too.
255
+ */
256
+ export async function resolveOrgId(a, opts = {}) {
257
+ const resolved = await resolveOrg(a, opts);
258
+ return resolved ? resolved.orgId : null;
259
+ }
260
+
261
+ /** Validate an org id supplied by a human, naming the origin. Throws via fail(). */
262
+ export function requireOrgIdShape(orgId, origin = "--org") {
263
+ return assertOrgIdShape(orgId, origin);
264
+ }
265
+
266
+ /** The profile's selected organization, without running the chain. */
267
+ export function getSelectedOrgId() {
268
+ return trimmed(coreGetActiveOrgId());
269
+ }
270
+
271
+ /** Record the profile's selected organization. */
272
+ export function setSelectedOrgId(orgId, origin = "org use") {
273
+ coreSetActiveOrgId(assertOrgIdShape(orgId, origin));
274
+ }
275
+
276
+ /** Clear the profile's selected organization. */
277
+ export function clearSelectedOrgId() {
278
+ coreClearActiveOrgId();
279
+ }
280
+
281
+ /**
282
+ * Stamp a project's owning organization as the profile selection.
283
+ *
284
+ * Called by `projects use`: a project determines its organization
285
+ * unambiguously, so selecting one keeps the two lowest classes from ever
286
+ * disagreeing in practice — and gives an existing user a current org without
287
+ * reading a changelog. Best-effort: a failure here must never fail the project
288
+ * selection the caller actually asked for.
289
+ */
290
+ export async function stampOrgFromProject(projectId) {
291
+ try {
292
+ const orgId = await orgOfProject(projectId, { required: false });
293
+ if (orgId) {
294
+ coreSetActiveOrgId(orgId);
295
+ return orgId;
296
+ }
297
+ } catch {
298
+ /* best-effort */
299
+ }
300
+ return null;
301
+ }
302
+
303
+ /** Provenance pair for command output: bounded, never a resolution trace. */
304
+ export function orgProvenance(resolved) {
305
+ return resolved
306
+ ? { org_id: resolved.orgId, org_source: resolved.source, org_source_detail: resolved.sourceDetail }
307
+ : { org_id: null, org_source: null, org_source_detail: null };
27
308
  }
28
309
 
29
310
  /**
package/lib/org.mjs CHANGED
@@ -1,9 +1,20 @@
1
1
  import { getSdk } from "./sdk.mjs";
2
2
  import { reportSdkError, fail } from "./sdk-errors.mjs";
3
+ import { readBindingFile, updateBindingFile } from "./wallet-context.mjs";
4
+ import { nextAction } from "./next-actions.mjs";
5
+ import {
6
+ requireOrgIdShape,
7
+ resolveOrg,
8
+ orgProvenance,
9
+ getSelectedOrgId,
10
+ setSelectedOrgId,
11
+ clearSelectedOrgId,
12
+ } from "./org-context.mjs";
3
13
  import {
4
14
  normalizeArgv,
5
15
  assertKnownFlags,
6
16
  flagValue,
17
+ positionalArgs,
7
18
  parseIntegerFlag,
8
19
  requirePositionalCount,
9
20
  failUnknownSubcommand,
@@ -20,6 +31,11 @@ Usage:
20
31
  run402 org rename <org_id> --name <display_name> (or: --clear to remove the label)
21
32
  run402 org payout-wallet <org_id> --wallet <wallet_address> (or: --clear to remove the explicit default)
22
33
  run402 org whoami
34
+ run402 org use <org_id>
35
+ run402 org current
36
+ run402 org clear
37
+ run402 org bind [--org <org_id>] [--room <key>]
38
+ run402 org unbind
23
39
  run402 org audit <org_id> [--limit N] [--after <cursor>] [--before <cursor>]
24
40
  run402 org member list <org_id>
25
41
  run402 org member add <org_id> --wallet <wallet_address> [--role <role>]
@@ -37,6 +53,11 @@ Subcommands:
37
53
  list Orgs you are a member of
38
54
  get Read one org (label + tier/lease + your role)
39
55
  rename Set or clear an org's display label (owner-only)
56
+ use Select the current org for this wallet profile
57
+ current Report the resolved current org and where it came from
58
+ clear Clear this wallet profile's org selection
59
+ bind Write this checkout's org (+room) into .run402.json — commit it
60
+ unbind Remove the org/room keys from .run402.json
40
61
  payout-wallet Set or clear the tenant route payout wallet (admin+)
41
62
  whoami Resolved principal + org memberships (GET /agent/v1/whoami)
42
63
  member Manage members (list, add, role, rm) — mutations require owner
@@ -198,6 +219,145 @@ async function whoami(args) {
198
219
  }
199
220
  }
200
221
 
222
+ // ── Current organization (add-cli-current-org) ─────────────────────────────────
223
+ //
224
+ // The selection is per WALLET PROFILE, not global: the chain is
225
+ // wallet -> principal -> memberships, so a global selection survives
226
+ // `wallets use other` and then either 403s or silently resolves to a
227
+ // valid-but-wrong org when both principals are members.
228
+
229
+ async function use(args) {
230
+ const a = normalizeArgv(args);
231
+ assertKnownFlags(a, ["--help", "-h"]);
232
+ requirePositionalCount(a, [], {
233
+ min: 1, max: 1, command: "run402 org use <org_id>", missing: "<org_id>",
234
+ });
235
+ const orgId = positionalArgs(a, [])[0];
236
+ setSelectedOrgId(orgId);
237
+ console.log(JSON.stringify({ org_id: orgId, selected: true, scope: "wallet_profile" }, null, 2));
238
+ }
239
+
240
+ async function clear(args) {
241
+ const a = normalizeArgv(args);
242
+ assertKnownFlags(a, ["--help", "-h"]);
243
+ requirePositionalCount(a, [], { min: 0, max: 0, command: "run402 org clear" });
244
+ const previous = getSelectedOrgId();
245
+ clearSelectedOrgId();
246
+ console.log(JSON.stringify({ org_id: null, selected: false, previous_org_id: previous ?? null }, null, 2));
247
+ }
248
+
249
+ /**
250
+ * Slugify a directory name into a legal room key.
251
+ * Room keys match /^[a-z0-9][a-z0-9._-]{0,63}$/ (the DB CHECK on every
252
+ * agent-messaging table), so the repo's own name has to be coerced, not trusted.
253
+ */
254
+ function roomKeyFromDir(dir) {
255
+ const base = dir.split("/").filter(Boolean).pop() ?? "";
256
+ const slug = base.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+/, "").slice(0, 64);
257
+ return /^[a-z0-9]/.test(slug) ? slug : null;
258
+ }
259
+
260
+ /**
261
+ * Write this checkout's org (and room) into `.run402.json`.
262
+ *
263
+ * WHY THIS PICKS FOR YOU WHEN YOU OWN EXACTLY ONE ORG, while the resolution
264
+ * chain never does: they are different acts. The chain runs on EVERY command
265
+ * and must not change meaning the day you are invited to a second org — so it
266
+ * refuses to infer. This runs ONCE, because you asked it to, and it WRITES THE
267
+ * ANSWER DOWN. Nothing is inferred afterwards; the file is read verbatim
268
+ * forever after. An explicit act with a recorded result is not a heuristic.
269
+ *
270
+ * With two or more orgs there is nothing to pick, so it lists them and stops.
271
+ */
272
+ async function bind(args) {
273
+ const a = normalizeArgv(args);
274
+ const valueFlags = ["--org", "--room"];
275
+ assertKnownFlags(a, [...valueFlags, "--help", "-h"], valueFlags);
276
+ requirePositionalCount(a, valueFlags, { min: 0, max: 0, command: "run402 org bind [--org <org_id>] [--room <key>]" });
277
+
278
+ let orgId = flagValue(a, "--org");
279
+ let picked = "flag";
280
+ if (!orgId) {
281
+ let orgs;
282
+ try {
283
+ orgs = await getSdk().orgs.list();
284
+ } catch (err) {
285
+ reportSdkError(err);
286
+ return;
287
+ }
288
+ const rows = Array.isArray(orgs) ? orgs : (orgs?.orgs ?? []);
289
+ if (rows.length === 0) {
290
+ fail({
291
+ code: "NO_ORGS",
292
+ message: "This wallet is a member of no organization yet.",
293
+ hint: "Run 'run402 init' to provision one, or ask an owner to add you with 'run402 org member add'.",
294
+ next_actions: [nextAction("initialize_wallet", { command: "run402 init", why: "Provision this wallet's organization, then retry." })],
295
+ });
296
+ }
297
+ if (rows.length > 1) {
298
+ fail({
299
+ code: "AMBIGUOUS_ORG",
300
+ message: `This wallet belongs to ${rows.length} organizations — name the one to bind.`,
301
+ hint: "run402 org bind --org <org_id>",
302
+ details: { orgs: rows.map((o) => ({ org_id: o.org_id, display_name: o.display_name ?? null, role: o.role ?? null })) },
303
+ next_actions: [nextAction("edit_request", { command: "run402 org bind --org <org_id>", why: "Name which organization this checkout coordinates in." })],
304
+ });
305
+ }
306
+ orgId = rows[0].org_id;
307
+ picked = "sole_membership";
308
+ }
309
+
310
+ const room = flagValue(a, "--room") ?? roomKeyFromDir(process.cwd());
311
+ const { contents, file } = updateBindingFile(process.cwd(), {
312
+ org: requireOrgIdShape(orgId, picked === "flag" ? "--org" : "org list"),
313
+ ...(room ? { room } : {}),
314
+ });
315
+ console.log(JSON.stringify({
316
+ org_id: orgId,
317
+ room_key: room ?? null,
318
+ org_source: picked,
319
+ file: ".run402.json",
320
+ path: file,
321
+ bound: true,
322
+ safe_to_commit: true,
323
+ note: "Safe to commit — an org id is an identifier, not a credential; authorization stays server-side.",
324
+ binding: contents,
325
+ }, null, 2));
326
+ }
327
+
328
+ async function unbind(args) {
329
+ const a = normalizeArgv(args);
330
+ assertKnownFlags(a, ["--help", "-h"]);
331
+ requirePositionalCount(a, [], { min: 0, max: 0, command: "run402 org unbind" });
332
+ const previous = readBindingFile(process.cwd());
333
+ const { contents, removed } = updateBindingFile(process.cwd(), { org: null, room: null });
334
+ console.log(JSON.stringify({
335
+ file: ".run402.json",
336
+ unbound: previous.org !== undefined || previous.room !== undefined,
337
+ removed,
338
+ binding: contents,
339
+ }, null, 2));
340
+ }
341
+
342
+ async function current(args) {
343
+ const a = normalizeArgv(args);
344
+ assertKnownFlags(a, ["--help", "-h"]);
345
+ requirePositionalCount(a, [], { min: 0, max: 0, command: "run402 org current" });
346
+ try {
347
+ // `cmd: "org"` exempts this from the ambiguity error on purpose: the
348
+ // command that reports the selection must stay usable while it is ambiguous.
349
+ // `optional` keeps an empty selection an explicit null state rather than a
350
+ // failure — reporting is not acting.
351
+ const resolved = await resolveOrg([], { cmd: "org", optional: true });
352
+ console.log(JSON.stringify({
353
+ ...orgProvenance(resolved),
354
+ selected_org_id: getSelectedOrgId() ?? null,
355
+ }, null, 2));
356
+ } catch (err) {
357
+ reportSdkError(err);
358
+ }
359
+ }
360
+
201
361
  async function get(args) {
202
362
  const a = normalizeArgv(args);
203
363
  assertKnownFlags(a, ["--help", "-h"]);
@@ -471,6 +631,11 @@ export async function run(sub, args) {
471
631
  case "rename": await rename(args); break;
472
632
  case "payout-wallet": await payoutWallet(args); break;
473
633
  case "whoami": await whoami(args); break;
634
+ case "use": await use(args); break;
635
+ case "current": await current(args); break;
636
+ case "clear": await clear(args); break;
637
+ case "bind": await bind(args); break;
638
+ case "unbind": await unbind(args); break;
474
639
  case "audit": await audit(args); break;
475
640
  default:
476
641
  failUnknownSubcommand("org", sub);
package/lib/projects.mjs CHANGED
@@ -5,6 +5,7 @@ import { loadLiveControlPlaneSession } from "../core-dist/control-plane-session.
5
5
  import { withAutoApprove } from "./operator.mjs";
6
6
  import { getSdk } from "./sdk.mjs";
7
7
  import { reportSdkError, fail, parseFlagJson } from "./sdk-errors.mjs";
8
+ import { stampOrgFromProject } from "./org-context.mjs";
8
9
  import { assertKnownFlags, failBadProjectId, flagValue, hasHelp, normalizeArgv, positionalArgs, resolveProjectSelector, validateRegularFile, failUnknownSubcommand } from "./argparse.mjs";
9
10
 
10
11
  const HELP = `run402 projects — Manage your deployed Run402 projects
@@ -802,7 +803,11 @@ async function use(projectId) {
802
803
  }
803
804
  try {
804
805
  await getSdk().projects.use(projectId);
805
- console.log(JSON.stringify({ active_project_id: projectId, set: true }));
806
+ // A project determines its org unambiguously, so selecting one also makes
807
+ // that org current (add-cli-current-org D4). Best-effort by construction:
808
+ // the project selection the caller asked for must not fail on this.
809
+ const orgId = await stampOrgFromProject(projectId);
810
+ console.log(JSON.stringify({ active_project_id: projectId, set: true, org_id: orgId ?? null }));
806
811
  } catch (err) {
807
812
  reportSdkError(err);
808
813
  }
@@ -5,6 +5,10 @@
5
5
  * Room addressing (precedence):
6
6
  * --org <org_id> + --room <key> explicit (named org rooms)
7
7
  * RUN402_ROOM=<org_id>/<key> env form of the same
8
+ * <room key> + the shared chain a room key from --room or the binding
9
+ * file's `room`, with the ORG resolved by
10
+ * `org-context.mjs` — this is what reaches a
11
+ * named room in a checkout with no project
8
12
  * (default) the project's DEFAULT room — the room key
9
13
  * IS the project id; org resolved via the
10
14
  * project overview (`rooms.forProject`).
@@ -21,8 +25,11 @@
21
25
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
26
  import { join } from "node:path";
23
27
  import { fail } from "./sdk-errors.mjs";
24
- import { resolveProjectId } from "./config.mjs";
28
+ import { getActiveProjectId } from "./config.mjs";
25
29
  import { getSdk } from "./sdk.mjs";
30
+ import { resolveOrg } from "./org-context.mjs";
31
+ import { findBindingKey } from "./wallet-context.mjs";
32
+ import { nextAction } from "./next-actions.mjs";
26
33
 
27
34
  export const ROOM_ENV = "RUN402_ROOM";
28
35
  export const PRESENCE_ENV = "RUN402_PRESENCE_ID";
@@ -30,16 +37,22 @@ export const PRESENCE_ENV = "RUN402_PRESENCE_ID";
30
37
  const STATE_DIR = ".run402";
31
38
  const STATE_FILE = "messaging.json";
32
39
 
33
- /** Resolve the addressed room to { orgId, roomKey } (one SDK lookup at most). */
40
+ /**
41
+ * Resolve the addressed room to { orgId, roomKey } (one SDK lookup at most).
42
+ *
43
+ * A room is a PAIR, and this function owns the pair. What it no longer owns is
44
+ * the org half of its own fallback: below the explicit forms, the org comes
45
+ * from the shared chain in `org-context.mjs` (flag → env → binding → profile
46
+ * selection → active project) while the room key comes from `--room`, the
47
+ * binding file's `room` key, or the project's default room. That is what makes
48
+ * a named room reachable in a checkout with no project link at all.
49
+ */
34
50
  export async function resolveRoom({ org, room, project } = {}) {
35
- if (room && !org) {
36
- fail({
37
- code: "BAD_USAGE",
38
- message: "--room names an org room and needs --org <org_id> beside it.",
39
- hint: "For the project's default room, omit both (or pass --project). For a named room: --org <org_id> --room <key>.",
40
- });
41
- }
42
- if (org && room) return { orgId: org, roomKey: room };
51
+ // 1. Both halves named explicitly.
52
+ if (org && room) return { orgId: org, roomKey: room, orgSource: "flag", orgSourceDetail: "--org" };
53
+
54
+ // 2. The compound env form names both, and keeps outranking everything below
55
+ // it a harness wired with RUN402_ROOM selects exactly the room it names.
43
56
  const envRoom = (process.env[ROOM_ENV] ?? "").trim();
44
57
  if (envRoom) {
45
58
  const slash = envRoom.indexOf("/");
@@ -50,11 +63,61 @@ export async function resolveRoom({ org, room, project } = {}) {
50
63
  details: { value: envRoom },
51
64
  });
52
65
  }
53
- return { orgId: envRoom.slice(0, slash), roomKey: envRoom.slice(slash + 1) };
66
+ return {
67
+ orgId: envRoom.slice(0, slash),
68
+ roomKey: envRoom.slice(slash + 1),
69
+ orgSource: "env",
70
+ orgSourceDetail: ROOM_ENV,
71
+ };
72
+ }
73
+
74
+ // 3. A room key without an org: the org resolves through the shared chain.
75
+ // (`--room` alone used to be a hard error; it now works whenever the org
76
+ // resolves, and fails with ORG_REQUIRED — naming every way to supply one —
77
+ // when it does not.)
78
+ const bindingRoom = findBindingKey(process.cwd(), "room");
79
+ const roomKey = room ?? bindingRoom?.value ?? null;
80
+ if (roomKey) {
81
+ const resolved = await resolveOrg({ org, project }, { cmd: "rooms" });
82
+ return {
83
+ orgId: resolved.orgId,
84
+ roomKey,
85
+ orgSource: resolved.source,
86
+ orgSourceDetail: resolved.sourceDetail,
87
+ };
88
+ }
89
+
90
+ // 4. No room key anywhere: the project's DEFAULT room, whose key IS the
91
+ // project id — the zero-config rendezvous carried by run402.config.json.
92
+ const effectiveProject = project || (process.env.RUN402_PROJECT_ID ?? "").trim() || getActiveProjectId();
93
+ if (!effectiveProject) {
94
+ fail({
95
+ code: "ROOM_REQUIRED",
96
+ message: "No room addressed and no current project to take a default room from.",
97
+ hint: `Pass --org <org_id> --room <key>, set ${ROOM_ENV}="<org_id>/<room_key>", add a "room" key to .run402.json, or select a project with: run402 projects use <project_id>`,
98
+ next_actions: [
99
+ nextAction("edit_request", {
100
+ command: "run402 rooms who --org <org_id> --room <key>",
101
+ why: "Name the organization and room on this one call.",
102
+ }),
103
+ nextAction("edit_request", {
104
+ command: `echo '{"org":"<org_id>","room":"<key>"}' > .run402.json`,
105
+ why: "Bind this checkout so every agent in it lands in the same room with no flags.",
106
+ }),
107
+ nextAction("edit_request", {
108
+ command: "run402 projects use <project_id>",
109
+ why: "Select a project to use its default room (the room key IS the project id).",
110
+ }),
111
+ ],
112
+ });
54
113
  }
55
- const projectId = resolveProjectId(project);
56
- const scoped = await getSdk().rooms.forProject(projectId);
57
- return { orgId: scoped.orgId, roomKey: scoped.roomKey };
114
+ const scoped = await getSdk().rooms.forProject(effectiveProject);
115
+ return {
116
+ orgId: scoped.orgId,
117
+ roomKey: scoped.roomKey,
118
+ orgSource: "profile",
119
+ orgSourceDetail: "projects use",
120
+ };
58
121
  }
59
122
 
60
123
  function statePath() {
package/lib/rooms.mjs CHANGED
@@ -50,6 +50,12 @@ Room addressing (all subcommands):
50
50
  --org <org_id> --room <key>
51
51
  A named org room (multi-repo products); also
52
52
  RUN402_ROOM=<org_id>/<key>.
53
+ --room <key> A named org room whose ORG comes from the current-org
54
+ chain: --org / RUN402_ORG / the 'org' key of the nearest
55
+ .run402.json / 'run402 org use'. A checkout that hosts
56
+ nothing on run402 reaches a room this way.
57
+ .run402.json {"org":"<org_id>","room":"<key>"} binds a checkout, so
58
+ every agent in it lands in the same room with no flags.
53
59
 
54
60
  Subcommands:
55
61
  who Who is live in the room (name, task, active claims). Registers your
@@ -155,6 +161,8 @@ async function who(args) {
155
161
  });
156
162
  console.log(JSON.stringify({
157
163
  org_id: room.orgId,
164
+ org_source: room.orgSource ?? null,
165
+ org_source_detail: room.orgSourceDetail ?? null,
158
166
  room_key: room.roomKey,
159
167
  you: me,
160
168
  ...page,
@@ -19,7 +19,7 @@
19
19
  * wallets and no flag is given, that is a hard error (not a silent pick).
20
20
  */
21
21
 
22
- import { readFileSync } from "node:fs";
22
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
23
23
  import { join, dirname, resolve } from "node:path";
24
24
  import { fail } from "./sdk-errors.mjs";
25
25
  import { isValidProfileName } from "../core-dist/config.js";
@@ -27,6 +27,7 @@ import { getDefaultWallet, profileExists, readMeta, profileDir } from "../core-d
27
27
  import { readAllowance } from "../core-dist/allowance.js";
28
28
 
29
29
  const DEFAULT = "default";
30
+ const BINDING_FILE = ".run402.json";
30
31
  const GLOBAL_FLAGS = new Set(["--wallet", "--profile"]);
31
32
  // The `wallets` group is the management + escape surface — it must work even
32
33
  // when selection is ambiguous (so you can `wallets unbind`), and it validates
@@ -69,14 +70,14 @@ export function splitWalletFlag(rawArgv = []) {
69
70
  return { argv, walletFlag: flag };
70
71
  }
71
72
 
72
- function readBindingFrom(dir) {
73
+ function readBindingKeyFrom(dir, key) {
73
74
  // .run402.local.json (gitignored personal override) beats .run402.json.
74
75
  for (const fname of [".run402.local.json", ".run402.json"]) {
75
76
  const p = join(dir, fname);
76
77
  try {
77
78
  const parsed = JSON.parse(readFileSync(p, "utf8"));
78
- const w = parsed?.wallet;
79
- if (typeof w === "string" && w.trim()) return { wallet: w.trim(), file: p };
79
+ const v = parsed?.[key];
80
+ if (typeof v === "string" && v.trim()) return { value: v.trim(), file: p };
80
81
  } catch {
81
82
  /* missing / unreadable / malformed → skip */
82
83
  }
@@ -84,18 +85,71 @@ function readBindingFrom(dir) {
84
85
  return null;
85
86
  }
86
87
 
87
- /** Nearest binding walking up from `startDir` to the filesystem root. */
88
- export function findBinding(startDir) {
88
+ /** Path of the committed binding file for a directory. */
89
+ export function bindingFilePath(dir = process.cwd()) {
90
+ return join(dir, BINDING_FILE);
91
+ }
92
+
93
+ /** Parse a directory's committed binding file, or `{}` when absent/unreadable. */
94
+ export function readBindingFile(dir = process.cwd()) {
95
+ try {
96
+ const parsed = JSON.parse(readFileSync(bindingFilePath(dir), "utf8"));
97
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
98
+ } catch {
99
+ return {};
100
+ }
101
+ }
102
+
103
+ /**
104
+ * MERGE keys into a directory's binding file. A `null` value removes its key;
105
+ * a file left with no keys is deleted rather than committed empty.
106
+ *
107
+ * The file is shared by tiers (`wallet` from `wallets bind`, `org`/`room` from
108
+ * `org bind`) and unknown keys are preserved, so one tier can never clobber
109
+ * another's binding — which a whole-file write did until this existed.
110
+ */
111
+ export function updateBindingFile(dir, patch) {
112
+ const file = bindingFilePath(dir);
113
+ const next = { ...readBindingFile(dir) };
114
+ for (const [k, v] of Object.entries(patch)) {
115
+ if (v === null || v === undefined) delete next[k];
116
+ else next[k] = v;
117
+ }
118
+ if (Object.keys(next).length === 0) {
119
+ const existed = existsSync(file);
120
+ if (existed) rmSync(file, { force: true });
121
+ return { file, contents: null, removed: existed };
122
+ }
123
+ writeFileSync(file, JSON.stringify(next, null, 2) + "\n");
124
+ return { file, contents: next, removed: false };
125
+ }
126
+
127
+ /**
128
+ * Nearest binding carrying `key`, walking up from `startDir` to the root.
129
+ *
130
+ * Keys bind INDEPENDENTLY (add-cli-current-org, design D7): a file declaring
131
+ * only `org` leaves wallet resolution untouched, and each key resolves at the
132
+ * nearest file that carries it — so `/work/.run402.json` may supply the org
133
+ * while `/work/api/.run402.json` supplies the wallet. Unknown keys are ignored,
134
+ * which is what makes older CLIs forward-compatible with these files.
135
+ */
136
+ export function findBindingKey(startDir, key) {
89
137
  let dir = resolve(startDir);
90
138
  for (;;) {
91
- const b = readBindingFrom(dir);
92
- if (b) return b;
139
+ const hit = readBindingKeyFrom(dir, key);
140
+ if (hit) return hit;
93
141
  const parent = dirname(dir);
94
142
  if (parent === dir) return null;
95
143
  dir = parent;
96
144
  }
97
145
  }
98
146
 
147
+ /** Nearest wallet binding walking up from `startDir` to the filesystem root. */
148
+ export function findBinding(startDir) {
149
+ const hit = findBindingKey(startDir, "wallet");
150
+ return hit ? { wallet: hit.value, file: hit.file } : null;
151
+ }
152
+
99
153
  function assertValidName(name, origin) {
100
154
  if (name === DEFAULT || isValidProfileName(name)) return;
101
155
  fail({
package/lib/wallets.mjs CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  } from "../core-dist/profiles.js";
33
33
  import { readAllowance, saveAllowance } from "../core-dist/allowance.js";
34
34
  import { getSdk } from "./sdk.mjs";
35
+ import { readBindingFile, updateBindingFile } from "./wallet-context.mjs";
35
36
 
36
37
  const DEFAULT = "default";
37
38
  const PRIVATE_KEY_RE = /^0x[0-9a-fA-F]{64}$/;
@@ -208,14 +209,15 @@ async function cmdRename(args) {
208
209
  function cmdBind(args) {
209
210
  let name = args.find((a) => a && !a.startsWith("-"));
210
211
  name = name ? requireName(name) : getActiveProfile();
211
- const file = join(process.cwd(), ".run402.json");
212
- writeFileSync(file, JSON.stringify({ wallet: name }, null, 2) + "\n");
212
+ // MERGE, never clobber: the same file carries `org`/`room` from `org bind`.
213
+ const { contents } = updateBindingFile(process.cwd(), { wallet: name });
213
214
  const result = {
214
215
  wallet: name,
215
216
  file: ".run402.json",
216
217
  bound: true,
217
218
  safe_to_commit: true,
218
219
  note: "Safe to commit — contains no secrets, only the wallet name.",
220
+ binding: contents,
219
221
  };
220
222
  if (name !== DEFAULT && !profileExists(name)) {
221
223
  result.warning = `No local wallet named '${name}' yet — create it with 'run402 wallets new ${name}'.`;
@@ -224,10 +226,12 @@ function cmdBind(args) {
224
226
  }
225
227
 
226
228
  function cmdUnbind() {
227
- const file = join(process.cwd(), ".run402.json");
228
- const existed = existsSync(file);
229
- if (existed) rmSync(file, { force: true });
230
- out({ file: ".run402.json", unbound: existed });
229
+ // Removes only the WALLET key. An `org`/`room` binding in the same file
230
+ // belongs to another tier and must survive; the file is deleted only when
231
+ // unbinding leaves nothing behind.
232
+ const had = readBindingFile(process.cwd()).wallet !== undefined;
233
+ const { contents, removed } = updateBindingFile(process.cwd(), { wallet: null });
234
+ out({ file: ".run402.json", unbound: had, removed, binding: contents });
231
235
  }
232
236
 
233
237
  async function cmdImport(args) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run402",
3
- "version": "4.26.0",
3
+ "version": "4.28.0",
4
4
  "description": "CLI for Run402 — provision Postgres databases, deploy static sites, generate images, and manage wallets via x402 and MPP micropayments.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -126,6 +126,52 @@ export function clearActiveProjectId(projectId, path, scope = {}) {
126
126
  saveProfileState(state, p);
127
127
  });
128
128
  }
129
+ // ---------------------------------------------------------------------------
130
+ // Selected organization (add-cli-current-org, design D3).
131
+ //
132
+ // Scoped exactly like the active project id — by api_base, profile, and
133
+ // principal — and stored in the SAME per-profile state.json, never in the
134
+ // base-level config.json beside `active_wallet`. The chain is
135
+ // wallet -> principal -> memberships, so a globally-selected org survives
136
+ // `wallets use other` and then either 403s or, worse, silently resolves to a
137
+ // valid-but-wrong org when both principals are members. Per-profile makes the
138
+ // failure mode "this profile has no current org", which the ORG_REQUIRED
139
+ // envelope already knows how to explain.
140
+ //
141
+ // There is deliberately no top-level `active_org_id` mirror: the project one
142
+ // exists only to carry pre-scoping installs forward, and this key is new.
143
+ // ---------------------------------------------------------------------------
144
+ export function getActiveOrgId(path, scope = {}) {
145
+ const state = loadProfileState(path);
146
+ return state.active_orgs?.[activeProjectScopeKey(scope)]?.org_id;
147
+ }
148
+ export function setActiveOrgId(orgId, path, scope = {}) {
149
+ const p = path ?? getProfileStatePath();
150
+ withFileLock(p, () => {
151
+ const state = loadProfileState(p);
152
+ const key = activeProjectScopeKey(scope);
153
+ const resolved = defaultActiveProjectScope(scope);
154
+ state.active_orgs = state.active_orgs ?? {};
155
+ state.active_orgs[key] = {
156
+ org_id: orgId,
157
+ api_base: resolved.api_base,
158
+ principal: resolved.principal ?? null,
159
+ profile: resolved.profile,
160
+ updated_at: new Date().toISOString(),
161
+ };
162
+ saveProfileState(state, p);
163
+ });
164
+ }
165
+ export function clearActiveOrgId(path, scope = {}) {
166
+ const p = path ?? getProfileStatePath();
167
+ withFileLock(p, () => {
168
+ const state = loadProfileState(p);
169
+ const key = activeProjectScopeKey(scope);
170
+ if (state.active_orgs?.[key])
171
+ delete state.active_orgs[key];
172
+ saveProfileState(state, p);
173
+ });
174
+ }
129
175
  export function recordMigration(marker, value, path) {
130
176
  const p = path ?? getProfileStatePath();
131
177
  withFileLock(p, () => {