@lotics/cli 0.35.0 → 0.36.1

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.
package/README.md CHANGED
@@ -39,42 +39,57 @@ lotics auth signup a@b.com --name "Agent" # non-interactive
39
39
  lotics auth web
40
40
  ```
41
41
 
42
- **`lotics auth api-key`** — Saves an existing API key directly (e.g. one created in the Lotics web app).
42
+ **`lotics auth api-key`** — Saves an API key (e.g. one created in the Lotics web app). The key belongs to one org, so this **registers that org as a profile** — run it once per org. Registering a second key adds a profile; it does not overwrite the first.
43
43
 
44
44
  ```bash
45
45
  lotics auth api-key # interactive prompt
46
- lotics auth api-key ltk_... # non-interactive
46
+ lotics auth api-key ltk_... # registers the key's org as a profile (now active)
47
47
  ```
48
48
 
49
- API key is saved to the config file. Run `lotics auth logout` to remove saved credentials.
49
+ Run `lotics auth logout [<name|id>]` to remove a profile (default: the active org), or `lotics auth logout --all` to wipe the store.
50
50
 
51
- Auth priority: `--api-key` flag > `LOTICS_API_KEY` env > config file.
51
+ ## Organizations
52
52
 
53
- ### Config file location
53
+ Each saved API key belongs to one org. Register a key per org once, then switch freely — no re-pasting:
54
54
 
55
- The CLI resolves `.lotics/config.json` by walking up from the current working directory — the first ancestor that has one wins, otherwise the global `~/.lotics/config.json`. A per-directory config lets a project or worktree pin its own account and workspace; commands run from a subdirectory still resolve to it.
55
+ ```bash
56
+ lotics org # list saved orgs (marks the active one)
57
+ lotics org use acme # switch active org by name (or org id)
58
+ lotics org use "Acme Corp" # names are case-insensitive
59
+ ```
60
+
61
+ ### Working in parallel (worktrees)
56
62
 
57
- Create one by passing `--local` to `lotics auth`:
63
+ Pin a directory to its own org/workspace so a global `org use` elsewhere never disturbs it. The pin is a pointer — the key still comes from the global store, so there's nothing to paste:
58
64
 
59
65
  ```bash
60
66
  cd my-worktree
61
- lotics auth api-key ltk_... --local # writes ./.lotics/config.json
62
- lotics workspace select wks_... # auto-resolves to the local config
67
+ lotics org use acme --local # writes ./.lotics/config.json { active_org }
68
+ lotics workspace select wks_... # records the workspace in the local pin
63
69
  ```
64
70
 
65
- `.lotics/` should be gitignored. Note: an exported `LOTICS_API_KEY` overrides the config file's key.
71
+ Each worktree resolves independently; switching the global default in another shell leaves pinned worktrees untouched. Use `lotics auth whoami` to see the active org, workspace, and which source resolved them. `.lotics/` should be gitignored.
72
+
73
+ ### Resolution precedence
74
+
75
+ ```
76
+ --api-key flag > LOTICS_API_KEY env > LOTICS_ORG env (name|id)
77
+ > local .lotics/config.json > global active profile
78
+ ```
79
+
80
+ `LOTICS_WORKSPACE` (or `--workspace <id>` / `-w`) overrides the workspace at any level. For ephemeral or CI use, set `LOTICS_API_KEY` instead of saving anything.
66
81
 
67
82
  ## Workspaces
68
83
 
69
- If your organization has multiple workspaces, select one before running tools:
84
+ Workspaces live inside the active org. If the org has more than one, select before running tools:
70
85
 
71
86
  ```bash
72
- lotics workspace # list workspaces (marks current)
73
- lotics workspace select wks_... # switch to a workspace
87
+ lotics workspace # list workspaces in the active org (marks current)
88
+ lotics workspace select wks_... # set the workspace for the active scope (pin or profile)
74
89
  lotics workspace create "Sales" # create a new workspace (admin only)
75
90
  ```
76
91
 
77
- Single-workspace organizations auto-select on first use.
92
+ Single-workspace organizations auto-select on first use. The selection is remembered per org, so switching back lands where you left off.
78
93
 
79
94
  ## CLI
80
95
 
@@ -97,8 +112,11 @@ lotics upload ./report.pdf ./data.csv ./documents/
97
112
  lotics run generate_excel_from_template '{"..."}'
98
113
  lotics download <file_id> -o ./reports/
99
114
 
100
- # CI / non-interactive
115
+ # CI / non-interactive (key inline)
101
116
  LOTICS_API_KEY=ltk_... lotics run query_tables '{}'
117
+
118
+ # One-off against a saved org/workspace, no switching
119
+ LOTICS_ORG=acme LOTICS_WORKSPACE=wks_... lotics run query_tables '{}'
102
120
  ```
103
121
 
104
122
  ## Local .xlsx and .docx authoring
package/dist/args.d.ts CHANGED
@@ -21,11 +21,13 @@ export declare function parseArgs(argv: string[]): {
21
21
  output?: string;
22
22
  as?: string;
23
23
  apiKey?: string;
24
+ workspace?: string;
24
25
  name?: string;
25
26
  timezone?: string;
26
27
  message?: string;
27
28
  forceWorkflowSync: boolean;
28
29
  local: boolean;
30
+ all: boolean;
29
31
  version: boolean;
30
32
  help: boolean;
31
33
  };
package/dist/args.js CHANGED
@@ -17,11 +17,13 @@ export function parseArgs(argv) {
17
17
  output: undefined,
18
18
  as: undefined,
19
19
  apiKey: undefined,
20
+ workspace: undefined,
20
21
  name: undefined,
21
22
  timezone: undefined,
22
23
  message: undefined,
23
24
  forceWorkflowSync: false,
24
25
  local: false,
26
+ all: false,
25
27
  version: false,
26
28
  help: false,
27
29
  };
@@ -49,6 +51,10 @@ export function parseArgs(argv) {
49
51
  case "--api-key":
50
52
  flags.apiKey = argv[++i];
51
53
  break;
54
+ case "--workspace":
55
+ case "-w":
56
+ flags.workspace = argv[++i];
57
+ break;
52
58
  case "--name":
53
59
  flags.name = argv[++i];
54
60
  break;
@@ -65,6 +71,9 @@ export function parseArgs(argv) {
65
71
  case "--local":
66
72
  flags.local = true;
67
73
  break;
74
+ case "--all":
75
+ flags.all = true;
76
+ break;
68
77
  case "--version":
69
78
  case "-v":
70
79
  flags.version = true;
package/dist/args.test.js CHANGED
@@ -34,4 +34,32 @@ describe("parseArgs", () => {
34
34
  expect(r.flags.forceWorkflowSync).toBe(false);
35
35
  expect(r.flags.message).toBeUndefined();
36
36
  });
37
+ it("parses --workspace as a value flag", () => {
38
+ const r = parseArgs(["run", "query_tables", "{}", "--workspace", "wsp_123"]);
39
+ expect(r.flags.workspace).toBe("wsp_123");
40
+ expect(r.command).toBe("run");
41
+ expect(r.subcommand).toBe("query_tables");
42
+ expect(r.toolArgs).toBe("{}");
43
+ });
44
+ it("parses -w as the workspace alias", () => {
45
+ const r = parseArgs(["run", "query_tables", "-w", "wsp_123"]);
46
+ expect(r.flags.workspace).toBe("wsp_123");
47
+ });
48
+ it("parses --all as a boolean flag", () => {
49
+ const r = parseArgs(["auth", "logout", "--all"]);
50
+ expect(r.flags.all).toBe(true);
51
+ expect(r.command).toBe("auth");
52
+ expect(r.subcommand).toBe("logout");
53
+ });
54
+ it("defaults workspace to undefined and all to false", () => {
55
+ const r = parseArgs(["org"]);
56
+ expect(r.flags.workspace).toBeUndefined();
57
+ expect(r.flags.all).toBe(false);
58
+ });
59
+ it("treats `org use <name>` as command / subcommand / positional", () => {
60
+ const r = parseArgs(["org", "use", "Acme Corp"]);
61
+ expect(r.command).toBe("org");
62
+ expect(r.subcommand).toBe("use");
63
+ expect(r.toolArgs).toBe("Acme Corp");
64
+ });
37
65
  });
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import readline from "node:readline";
5
5
  import { LoticsClient, API_BASE_URL } from "./client.js";
6
- import { resolveAuth, loadConfig, saveConfig, deleteConfig, getConfigPath, checkForUpdate } from "./config.js";
6
+ import { resolveContext, deleteConfig, getConfigPath, loadGlobalConfig, saveGlobalConfig, loadLocalConfig, upsertProfile, removeProfile, setActiveOrg, setSelectedWorkspace, resolveProfileByNameOrId, checkForUpdate, } from "./config.js";
7
7
  import { VERSION } from "./version.js";
8
8
  import { appCreate, appPull, appDeploy, appDev, appSetSubdomain, appRename } from "./app_commands.js";
9
9
  import { parseArgs } from "./args.js";
@@ -24,20 +24,31 @@ Lotics is an AI-powered operations platform. Through this CLI you can:
24
24
  AUTHENTICATION
25
25
  lotics auth Show auth help
26
26
  lotics auth signup <email> Create account (run "lotics auth" for details)
27
+ lotics auth api-key <key> Save a key — registers its org as a profile
28
+
29
+ ORGANIZATIONS
30
+ Each saved API key belongs to one org. Register a key per org once, then
31
+ switch between them with no re-pasting. Workspaces live inside an org.
32
+ lotics org List saved orgs (marks active)
33
+ lotics org use <name|id> Switch the active org
34
+ lotics org use <name|id> --local Pin THIS directory to an org (worktrees)
27
35
 
28
36
  USAGE
29
37
  1. lotics auth signup <email> Create account or authenticate
30
- 2. lotics workspace Check current workspace (auto-selects if only one)
31
- 3. lotics tools List available tools by category
32
- 4. lotics tools <name> Show tool description and full input schema
33
- 5. lotics run <tool> '<json>' Execute a tool with JSON arguments
38
+ 2. lotics org See saved orgs / which one is active
39
+ 3. lotics workspace Check current workspace (auto-selects if only one)
40
+ 4. lotics tools List available tools by category
41
+ 5. lotics tools <name> Show tool description and full input schema
42
+ 6. lotics run <tool> '<json>' Execute a tool with JSON arguments
34
43
 
35
44
  Always inspect the schema (step 4) before calling a tool.
36
45
  Tools are grouped by category (tables, records, views, etc.).
37
46
  Query tools return IDs used as arguments to other tools.
38
47
 
39
48
  COMMANDS
40
- lotics workspace List workspaces (marks current)
49
+ lotics org List saved orgs (marks active)
50
+ lotics org use <name|id> [--local] Switch active org (--local pins this dir)
51
+ lotics workspace List workspaces in the active org (marks current)
41
52
  lotics workspace select <id> Switch to a different workspace
42
53
  lotics workspace create <name> Create a new workspace (admin only)
43
54
  lotics tools List all available tools
@@ -68,15 +79,23 @@ FLAGS
68
79
  --timeout <ms> Timeout for tool execution (default: 60000)
69
80
  -o <path> Output dir for downloads
70
81
  --as <name> Override upload filename
71
- --api-key <key> API key (overrides saved config and LOTICS_API_KEY)
72
- --local (lotics auth) Save credentials to ./.lotics/config.json
82
+ --api-key <key> One-off API key (overrides saved config + env)
83
+ --workspace <id> One-off workspace override (alias: -w)
84
+ --local Pin the current directory (lotics org use / auth api-key)
85
+ --all (lotics auth logout) Remove every saved credential
73
86
  --version Show version
74
87
 
75
88
  CONFIG
76
- Credentials resolve from a .lotics/config.json found by walking up from the
77
- current directory, else the global ~/.lotics/config.json. Run "lotics auth
78
- api-key <key> --local" inside a directory (e.g. a worktree) to pin it to its
79
- own account and workspace.
89
+ API keys are stored once per org in the global ~/.lotics/config.json as named
90
+ profiles. Switch with "lotics org use <name|id>" — no re-pasting. A directory
91
+ (e.g. a git worktree) can pin its own org/workspace with "lotics org use
92
+ <name|id> --local", which writes a .lotics/config.json pointer (the key is
93
+ resolved from the global store) — so a global switch never disturbs it.
94
+
95
+ Resolution precedence (highest first):
96
+ --api-key flag > LOTICS_API_KEY env > LOTICS_ORG env >
97
+ local .lotics/config.json > global active profile
98
+ LOTICS_WORKSPACE (or --workspace) overrides the workspace at any level.
80
99
 
81
100
  OUTPUT
82
101
  Default output is a compact text summary optimized for AI agents —
@@ -105,25 +124,27 @@ function printAuthHelp() {
105
124
  console.log(`Authentication commands:
106
125
 
107
126
  lotics auth signup <email> Create account, org, workspace, and API key
108
- lotics auth api-key [key] Save an existing API key (e.g. from the web app)
127
+ lotics auth api-key [key] Save an API key registers its org as a profile
109
128
  lotics auth web Send a magic link email to access the web app
110
- lotics auth whoami Show the current account's name, email, and organization
111
- lotics auth logout Remove saved credentials
129
+ lotics auth whoami Show the active account, org, workspace, and source
130
+ lotics auth logout [<name|id>] Remove a saved credential (default: the active org,
131
+ or unpin the current directory)
132
+ lotics auth logout --all Remove every saved credential
112
133
 
113
134
  Auth flags:
114
- --local Save credentials to ./.lotics/config.json (this directory)
115
- instead of the global ~/.lotics — applies to signup + api-key
135
+ --local Pin the current directory instead of the global store.
136
+ signup/api-key write a self-contained ./.lotics/config.json
137
+ (inline key) — for a hermetic worktree or CI.
116
138
  --name <name> (signup) Display name (defaults to email prefix)
117
139
  --timezone <timezone> (signup) Workspace timezone (defaults to UTC, e.g. Asia/Ho_Chi_Minh)
118
140
 
119
- Signup sends a magic link email so you can access the Lotics web app.
120
- Use lotics auth web to request a new magic link at any time.
141
+ API keys are stored once per org as named profiles in ~/.lotics/config.json.
142
+ Registering a second key adds a profile it does not overwrite the first.
143
+ Switch active org with "lotics org use <name|id>".
121
144
 
122
- Auth priority: --api-key flag > LOTICS_API_KEY env > config file.
123
- Config file: .lotics/config.json found by walking up from the current directory,
124
- else ~/.lotics/config.json. A per-directory config pins a project or worktree to
125
- its own account and workspace; --local creates one. Note: an exported
126
- LOTICS_API_KEY env var overrides the config file's key.`);
145
+ Resolution precedence (highest first):
146
+ --api-key flag > LOTICS_API_KEY env > LOTICS_ORG env > local .lotics/config.json
147
+ > global active profile. LOTICS_WORKSPACE / --workspace override the workspace.`);
127
148
  }
128
149
  function readStdin() {
129
150
  return new Promise((resolve, reject) => {
@@ -179,17 +200,25 @@ async function handleSignup(positionalEmail, flags) {
179
200
  }
180
201
  process.exit(1);
181
202
  }
182
- const scope = flags.local ? "local" : "auto";
183
- const existing = loadConfig(scope) ?? {};
184
- saveConfig({
185
- ...existing,
186
- api_key: data.api_key,
187
- email: data.email,
188
- workspace_id: data.workspace_id,
189
- }, scope);
203
+ const apiKey = data.api_key;
204
+ const orgId = data.organization_id;
205
+ const workspaceId = data.workspace_id;
206
+ // The org is created with the display name; mirror the server's email-prefix
207
+ // default when none was supplied. Use `||` (not `??`) so an empty-string name
208
+ // falls through exactly as the server's `if (name)` truthiness check does.
209
+ const orgName = name || email.split("@")[0];
210
+ // The key always lives in the global store as a profile; --local additionally
211
+ // pins this directory to it (a pointer), instead of making it the global default.
212
+ upsertProfile(orgId, { api_key: apiKey, org_name: orgName, workspace_id: workspaceId });
213
+ const existing = loadGlobalConfig() ?? {};
214
+ saveGlobalConfig({ ...existing, email });
215
+ setActiveOrg(orgId, flags.local ? "local" : "global");
190
216
  console.error(`Account created. You can now use the CLI.`);
191
- console.error(` Email: ${data.email}`);
192
- console.error(` Config: ${getConfigPath(scope)}`);
217
+ console.error(` Email: ${email}`);
218
+ console.error(` Org: ${orgName} (${orgId})`);
219
+ console.error(flags.local
220
+ ? ` Pinned this directory: ${getConfigPath("local")}`
221
+ : ` Saved to the "${orgName}" profile in ~/.lotics (now active)`);
193
222
  console.error(`\nCheck your email for a magic link to access the Lotics web app.`);
194
223
  console.error(`Run "lotics auth web" to request a new link at any time.`);
195
224
  }
@@ -200,27 +229,24 @@ async function handleSetup(providedKey, local) {
200
229
  process.exit(1);
201
230
  }
202
231
  const client = new LoticsClient({ apiKey });
203
- let email;
232
+ let info;
204
233
  try {
205
- const info = await client.whoami();
206
- email = info.email;
234
+ info = await client.whoami();
207
235
  }
208
236
  catch (error) {
209
237
  const message = error instanceof Error ? error.message : String(error);
210
238
  console.error(`Authentication failed: ${message}`);
211
239
  process.exit(1);
212
240
  }
213
- const scope = local ? "local" : "auto";
214
- const existing = loadConfig(scope) ?? {};
215
- const newConfig = { ...existing, api_key: apiKey, email };
216
- // Auto-resolve workspace
241
+ // Auto-resolve workspace (only when the org has exactly one).
242
+ let workspaceId;
217
243
  try {
218
244
  const workspaces = await client.listWorkspaces();
219
245
  if (workspaces.length === 1) {
220
- newConfig.workspace_id = workspaces[0].id;
246
+ workspaceId = workspaces[0].id;
221
247
  }
222
248
  else if (workspaces.length > 1) {
223
- console.error(`\nMultiple workspaces found. Run "lotics workspace select <id>" to choose one:`);
249
+ console.error(`\nMultiple workspaces in ${info.organization_name}. Run "lotics workspace select <id>" to choose one:`);
224
250
  printWorkspaceList(workspaces);
225
251
  }
226
252
  }
@@ -228,29 +254,51 @@ async function handleSetup(providedKey, local) {
228
254
  const msg = error instanceof Error ? error.message : String(error);
229
255
  console.error(`Warning: could not resolve workspace: ${msg}`);
230
256
  }
231
- saveConfig(newConfig, scope);
232
- console.error("Authenticated.");
233
- console.error(` Config: ${getConfigPath(scope)}`);
257
+ // The key always lives in the global store as a profile; --local additionally
258
+ // pins this directory to it (a pointer), instead of making it the global default.
259
+ upsertProfile(info.organization_id, {
260
+ api_key: apiKey,
261
+ org_name: info.organization_name,
262
+ workspace_id: workspaceId,
263
+ });
264
+ const existing = loadGlobalConfig() ?? {};
265
+ saveGlobalConfig({ ...existing, email: info.email });
266
+ setActiveOrg(info.organization_id, local ? "local" : "global");
267
+ if (local) {
268
+ console.error(`Authenticated as ${info.email} in ${info.organization_name}.`);
269
+ console.error(` Pinned this directory: ${getConfigPath("local")}`);
270
+ }
271
+ else {
272
+ console.error(`Authenticated as ${info.email} in ${info.organization_name} (now active).`);
273
+ console.error(` Saved to the "${info.organization_name}" profile in ~/.lotics`);
274
+ }
234
275
  }
235
276
  function requireClient(flags) {
236
- const auth = resolveAuth(flags);
237
- if (!auth) {
238
- console.error('Not authenticated. Run "lotics auth signup" or set LOTICS_API_KEY.');
277
+ const ctx = resolveContext(flags);
278
+ if (!ctx) {
279
+ console.error('Not authenticated. Run "lotics auth signup", "lotics auth api-key <key>", or set LOTICS_API_KEY.');
239
280
  process.exit(1);
240
281
  }
241
- const config = loadConfig();
242
- return new LoticsClient({ apiKey: auth.apiKey, workspaceId: config?.workspace_id });
282
+ return { client: new LoticsClient({ apiKey: ctx.apiKey, workspaceId: ctx.workspaceId }), ctx };
243
283
  }
284
+ const SOURCE_LABELS = {
285
+ flag: "--api-key flag",
286
+ env_key: "LOTICS_API_KEY env",
287
+ env_org: "LOTICS_ORG env",
288
+ local_pointer: "local .lotics/config.json (pin)",
289
+ global_profile: "global active profile",
290
+ };
244
291
  function printWorkspaceList(workspaces, currentId) {
245
292
  for (const ws of workspaces) {
246
293
  const marker = ws.id === currentId ? " (current)" : "";
247
294
  console.error(` ${ws.id} ${ws.name} ${ws.timezone} ${ws.default_currency}${marker}`);
248
295
  }
249
296
  }
250
- async function resolveWorkspace(client) {
251
- const config = loadConfig();
252
- if (config?.workspace_id)
297
+ async function resolveWorkspace(client, ctx) {
298
+ if (ctx.workspaceId) {
299
+ client.setWorkspaceId(ctx.workspaceId);
253
300
  return;
301
+ }
254
302
  let workspaces;
255
303
  try {
256
304
  workspaces = await client.listWorkspaces();
@@ -265,8 +313,9 @@ async function resolveWorkspace(client) {
265
313
  process.exit(1);
266
314
  }
267
315
  if (workspaces.length === 1) {
268
- const existing = config ?? {};
269
- saveConfig({ ...existing, workspace_id: workspaces[0].id });
316
+ // Persist the auto-resolution where the credential lives; null means it
317
+ // came from --api-key/env with no profile, so it's an in-memory selection.
318
+ setSelectedWorkspace(workspaces[0].id);
270
319
  client.setWorkspaceId(workspaces[0].id);
271
320
  return;
272
321
  }
@@ -320,33 +369,66 @@ async function main() {
320
369
  return;
321
370
  }
322
371
  if (subcommand === "whoami") {
323
- const auth = resolveAuth(flags);
324
- if (!auth) {
325
- console.error('Not authenticated. Run "lotics auth signup" or set LOTICS_API_KEY.');
372
+ const ctx = resolveContext(flags);
373
+ if (!ctx) {
374
+ console.error('Not authenticated. Run "lotics auth signup", "lotics auth api-key <key>", or set LOTICS_API_KEY.');
326
375
  process.exit(1);
327
376
  }
328
- const client = new LoticsClient({ apiKey: auth.apiKey });
377
+ const client = new LoticsClient({ apiKey: ctx.apiKey });
329
378
  const info = await client.whoami();
330
- const existing = loadConfig() ?? {};
331
- saveConfig({ ...existing, email: info.email });
379
+ const existing = loadGlobalConfig() ?? {};
380
+ saveGlobalConfig({ ...existing, email: info.email });
332
381
  if (flags.json) {
333
- console.log(JSON.stringify(info, null, 2));
382
+ console.log(JSON.stringify({ ...info, workspace_id: ctx.workspaceId ?? null, source: ctx.source }, null, 2));
334
383
  }
335
384
  else {
336
- console.log(`Name: ${info.name}`);
337
- console.log(`Email: ${info.email}`);
338
- console.log(`Org: ${info.organization_name} (${info.organization_id})`);
385
+ console.log(`Name: ${info.name}`);
386
+ console.log(`Email: ${info.email}`);
387
+ console.log(`Org: ${info.organization_name} (${info.organization_id})`);
388
+ console.log(`Workspace: ${ctx.workspaceId ?? "(none selected)"}`);
389
+ console.log(`Source: ${SOURCE_LABELS[ctx.source]}`);
339
390
  }
340
- console.error(`Config: ${getConfigPath()}`);
341
391
  return;
342
392
  }
343
393
  if (subcommand === "logout") {
344
- deleteConfig();
345
- console.error("Logged out. Credentials removed.");
394
+ const local = loadLocalConfig();
395
+ // Bare logout inside a pinned directory just unpins it.
396
+ if (local && !flags.all && !toolArgs) {
397
+ deleteConfig();
398
+ console.error("Removed the local pin for this directory.");
399
+ return;
400
+ }
401
+ if (flags.all) {
402
+ const g = loadGlobalConfig();
403
+ saveGlobalConfig({ last_update_check: g?.last_update_check, latest_version: g?.latest_version });
404
+ console.error("Removed all saved credentials.");
405
+ return;
406
+ }
407
+ const g = loadGlobalConfig() ?? {};
408
+ const profiles = g.profiles ?? {};
409
+ let resolved;
410
+ if (toolArgs) {
411
+ resolved = resolveProfileByNameOrId(profiles, toolArgs);
412
+ }
413
+ else if (g.active_org && profiles[g.active_org]) {
414
+ resolved = [g.active_org, profiles[g.active_org]];
415
+ }
416
+ else {
417
+ resolved = null;
418
+ }
419
+ if (!resolved) {
420
+ console.error(toolArgs
421
+ ? `No saved credential matches "${toolArgs}".`
422
+ : 'No active credential to remove. Pass an org name/id, or use --all.');
423
+ process.exit(1);
424
+ }
425
+ const [orgId, profile] = resolved;
426
+ removeProfile(orgId);
427
+ console.error(`Removed the "${profile.org_name}" credential (${orgId}).`);
346
428
  return;
347
429
  }
348
430
  if (subcommand === "web") {
349
- const client = requireClient(flags);
431
+ const { client } = requireClient(flags);
350
432
  const { email } = await client.login();
351
433
  console.error(`Magic link sent to ${email}. Check your email to access the Lotics web app.`);
352
434
  return;
@@ -366,6 +448,63 @@ async function main() {
366
448
  await runDocxCommand(subcommand, toolArgs, restArgs);
367
449
  return;
368
450
  }
451
+ // --- lotics org [list | use <name|id> [--local]] — credential store, config-only ---
452
+ if (command === "org") {
453
+ const global = loadGlobalConfig() ?? {};
454
+ const profiles = global.profiles ?? {};
455
+ const local = loadLocalConfig();
456
+ if (subcommand === "use") {
457
+ if (!toolArgs) {
458
+ console.error("Usage: lotics org use <name|id> [--local]");
459
+ process.exit(1);
460
+ }
461
+ const resolved = resolveProfileByNameOrId(profiles, toolArgs);
462
+ if (!resolved) {
463
+ console.error(`No saved credential for "${toolArgs}". Run "lotics auth api-key <key>" while authenticated to that org, or "lotics org" to list.`);
464
+ process.exit(1);
465
+ }
466
+ const [orgId, profile] = resolved;
467
+ if (flags.local) {
468
+ setActiveOrg(orgId, "local");
469
+ console.error(`Pinned this directory to ${profile.org_name} (${orgId}).`);
470
+ }
471
+ else {
472
+ setActiveOrg(orgId, "global");
473
+ console.error(`Switched active org to ${profile.org_name} (${orgId}).`);
474
+ if (local?.active_org) {
475
+ console.error("Note: a local pin (.lotics/config.json) overrides the global default in this directory. Use --local to change the pin here.");
476
+ }
477
+ }
478
+ return;
479
+ }
480
+ if (subcommand && subcommand !== "list") {
481
+ console.error(`Unknown org subcommand: ${subcommand}`);
482
+ console.error("Usage: lotics org [list | use <name|id> [--local]]");
483
+ process.exit(1);
484
+ }
485
+ const orgIds = Object.keys(profiles);
486
+ if (orgIds.length === 0) {
487
+ console.error('No saved orgs. Run "lotics auth api-key <key>" to add one.');
488
+ return;
489
+ }
490
+ // The active org in THIS directory: a local pin wins over the global default.
491
+ const effectiveActive = local?.active_org ?? global.active_org;
492
+ if (flags.json) {
493
+ console.log(JSON.stringify(orgIds.map((id) => ({
494
+ org_id: id,
495
+ org_name: profiles[id].org_name,
496
+ workspace_id: profiles[id].workspace_id ?? null,
497
+ active: id === effectiveActive,
498
+ })), null, 2));
499
+ }
500
+ else {
501
+ for (const id of orgIds) {
502
+ const marker = id === effectiveActive ? " (active)" : "";
503
+ console.log(`${id} ${profiles[id].org_name}${marker}`);
504
+ }
505
+ }
506
+ return;
507
+ }
369
508
  // --- Validate command before auth ---
370
509
  if (command !== "tools" && command !== "upload" && command !== "run" && command !== "download" && command !== "workspace" && command !== "app") {
371
510
  console.error(`Unknown command: ${command}`);
@@ -405,11 +544,11 @@ async function main() {
405
544
  console.error('Downloads every file on the given file field into the output dir.');
406
545
  process.exit(1);
407
546
  }
408
- const client = requireClient(flags);
547
+ const { client, ctx } = requireClient(flags);
409
548
  // lotics workspace / lotics workspace list / lotics workspace select <id>
410
549
  if (command === "workspace") {
411
550
  const workspaces = await client.listWorkspaces();
412
- const config = loadConfig();
551
+ const currentWorkspaceId = ctx.workspaceId;
413
552
  if (subcommand === "select") {
414
553
  const targetId = toolArgs;
415
554
  if (!targetId) {
@@ -419,12 +558,16 @@ async function main() {
419
558
  const target = workspaces.find((ws) => ws.id === targetId);
420
559
  if (!target) {
421
560
  console.error(`Workspace not found: ${targetId}\n\nAvailable workspaces:`);
422
- printWorkspaceList(workspaces, config?.workspace_id);
561
+ printWorkspaceList(workspaces, currentWorkspaceId);
423
562
  process.exit(1);
424
563
  }
425
- const existing = config ?? {};
426
- saveConfig({ ...existing, workspace_id: target.id });
427
- console.error(`Switched to workspace: ${target.name} (${target.id})`);
564
+ const written = setSelectedWorkspace(target.id);
565
+ if (!written) {
566
+ console.error(`Switched to ${target.name} (${target.id}) for this command, but it could not be saved — credentials came from --api-key/LOTICS_API_KEY with no saved profile. Run "lotics auth api-key <key>" to persist a default.`);
567
+ return;
568
+ }
569
+ const where = written.scope === "local" ? " (pinned to this directory)" : "";
570
+ console.error(`Switched to workspace: ${target.name} (${target.id})${where}`);
428
571
  return;
429
572
  }
430
573
  if (subcommand === "create") {
@@ -435,8 +578,7 @@ async function main() {
435
578
  }
436
579
  const timezone = flags.timezone;
437
580
  const created = await client.createWorkspace({ name, timezone });
438
- const existing = config ?? {};
439
- saveConfig({ ...existing, workspace_id: created.id });
581
+ setSelectedWorkspace(created.id);
440
582
  client.setWorkspaceId(created.id);
441
583
  if (flags.json) {
442
584
  console.log(JSON.stringify(created, null, 2));
@@ -462,16 +604,17 @@ async function main() {
462
604
  }
463
605
  else {
464
606
  for (const ws of workspaces) {
465
- const marker = ws.id === config?.workspace_id ? " (current)" : "";
607
+ const marker = ws.id === currentWorkspaceId ? " (current)" : "";
466
608
  console.log(`${ws.id} ${ws.name} ${ws.timezone} ${ws.default_currency}${marker}`);
467
609
  }
468
610
  }
469
611
  }
470
- console.error(`Config: ${getConfigPath()}`);
612
+ if (ctx.orgName)
613
+ console.error(`Org: ${ctx.orgName}`);
471
614
  return;
472
615
  }
473
616
  // Ensure workspace is resolved for all remaining commands
474
- await resolveWorkspace(client);
617
+ await resolveWorkspace(client, ctx);
475
618
  // lotics app create / pull / deploy
476
619
  if (command === "app") {
477
620
  if (subcommand === "create") {