@lotics/cli 0.24.1 → 0.25.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.
package/README.md CHANGED
@@ -46,9 +46,23 @@ lotics auth api-key # interactive prompt
46
46
  lotics auth api-key ltk_... # non-interactive
47
47
  ```
48
48
 
49
- API key is saved to `~/.lotics/config.json`. Run `lotics auth logout` to remove saved credentials.
49
+ API key is saved to the config file. Run `lotics auth logout` to remove saved credentials.
50
50
 
51
- Auth priority: `--api-key` flag > `LOTICS_API_KEY` env > `~/.lotics/config.json`.
51
+ Auth priority: `--api-key` flag > `LOTICS_API_KEY` env > config file.
52
+
53
+ ### Config file location
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.
56
+
57
+ Create one by passing `--local` to `lotics auth`:
58
+
59
+ ```bash
60
+ 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
63
+ ```
64
+
65
+ `.lotics/` should be gitignored. Note: an exported `LOTICS_API_KEY` overrides the config file's key.
52
66
 
53
67
  ## Workspaces
54
68
 
package/dist/src/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, checkForUpdate } from "./config.js";
6
+ import { resolveAuth, loadConfig, saveConfig, deleteConfig, getConfigPath, checkForUpdate } from "./config.js";
7
7
  import { VERSION } from "./version.js";
8
8
  import { appCreate, appPull, appDeploy, appDev } from "./app_commands.js";
9
9
  function printHelp() {
@@ -58,8 +58,15 @@ FLAGS
58
58
  -o <path> Output dir for downloads
59
59
  --as <name> Override upload filename
60
60
  --api-key <key> API key (overrides saved config and LOTICS_API_KEY)
61
+ --local (lotics auth) Save credentials to ./.lotics/config.json
61
62
  --version Show version
62
63
 
64
+ CONFIG
65
+ Credentials resolve from a .lotics/config.json found by walking up from the
66
+ current directory, else the global ~/.lotics/config.json. Run "lotics auth
67
+ api-key <key> --local" inside a directory (e.g. a worktree) to pin it to its
68
+ own account and workspace.
69
+
63
70
  OUTPUT
64
71
  Default output is a compact text summary optimized for AI agents —
65
72
  use it directly, no parsing needed. --json returns raw structured
@@ -92,13 +99,20 @@ function printAuthHelp() {
92
99
  lotics auth whoami Show the current account's name, email, and organization
93
100
  lotics auth logout Remove saved credentials
94
101
 
95
- Signup flags:
96
- --name <name> Display name (defaults to email prefix)
97
- --timezone <timezone> Workspace timezone (defaults to UTC, e.g. Asia/Ho_Chi_Minh)
102
+ Auth flags:
103
+ --local Save credentials to ./.lotics/config.json (this directory)
104
+ instead of the global ~/.lotics — applies to signup + api-key
105
+ --name <name> (signup) Display name (defaults to email prefix)
106
+ --timezone <timezone> (signup) Workspace timezone (defaults to UTC, e.g. Asia/Ho_Chi_Minh)
98
107
 
99
108
  Signup sends a magic link email so you can access the Lotics web app.
100
109
  Use lotics auth web to request a new magic link at any time.
101
- Auth priority: --api-key flag > LOTICS_API_KEY env > saved config.`);
110
+
111
+ Auth priority: --api-key flag > LOTICS_API_KEY env > config file.
112
+ Config file: .lotics/config.json found by walking up from the current directory,
113
+ else ~/.lotics/config.json. A per-directory config pins a project or worktree to
114
+ its own account and workspace; --local creates one. Note: an exported
115
+ LOTICS_API_KEY env var overrides the config file's key.`);
102
116
  }
103
117
  function parseArgs(argv) {
104
118
  const flags = {
@@ -109,6 +123,7 @@ function parseArgs(argv) {
109
123
  apiKey: undefined,
110
124
  name: undefined,
111
125
  timezone: undefined,
126
+ local: false,
112
127
  version: false,
113
128
  help: false,
114
129
  };
@@ -142,6 +157,9 @@ function parseArgs(argv) {
142
157
  case "--timezone":
143
158
  flags.timezone = argv[++i];
144
159
  break;
160
+ case "--local":
161
+ flags.local = true;
162
+ break;
145
163
  case "--version":
146
164
  case "-v":
147
165
  flags.version = true;
@@ -223,19 +241,21 @@ async function handleSignup(positionalEmail, flags) {
223
241
  }
224
242
  process.exit(1);
225
243
  }
226
- const existing = loadConfig() ?? {};
244
+ const scope = flags.local ? "local" : "auto";
245
+ const existing = loadConfig(scope) ?? {};
227
246
  saveConfig({
228
247
  ...existing,
229
248
  api_key: data.api_key,
230
249
  email: data.email,
231
250
  workspace_id: data.workspace_id,
232
- });
251
+ }, scope);
233
252
  console.error(`Account created. You can now use the CLI.`);
234
- console.error(` Email: ${data.email}`);
253
+ console.error(` Email: ${data.email}`);
254
+ console.error(` Config: ${getConfigPath(scope)}`);
235
255
  console.error(`\nCheck your email for a magic link to access the Lotics web app.`);
236
256
  console.error(`Run "lotics auth web" to request a new link at any time.`);
237
257
  }
238
- async function handleSetup(providedKey) {
258
+ async function handleSetup(providedKey, local) {
239
259
  const apiKey = providedKey ?? await prompt("Enter your API key: ");
240
260
  if (!apiKey) {
241
261
  console.error("No API key provided.");
@@ -252,7 +272,8 @@ async function handleSetup(providedKey) {
252
272
  console.error(`Authentication failed: ${message}`);
253
273
  process.exit(1);
254
274
  }
255
- const existing = loadConfig() ?? {};
275
+ const scope = local ? "local" : "auto";
276
+ const existing = loadConfig(scope) ?? {};
256
277
  const newConfig = { ...existing, api_key: apiKey, email };
257
278
  // Auto-resolve workspace
258
279
  try {
@@ -269,8 +290,9 @@ async function handleSetup(providedKey) {
269
290
  const msg = error instanceof Error ? error.message : String(error);
270
291
  console.error(`Warning: could not resolve workspace: ${msg}`);
271
292
  }
272
- saveConfig(newConfig);
293
+ saveConfig(newConfig, scope);
273
294
  console.error("Authenticated.");
295
+ console.error(` Config: ${getConfigPath(scope)}`);
274
296
  }
275
297
  function requireClient(flags) {
276
298
  const auth = resolveAuth(flags);
@@ -356,7 +378,7 @@ async function main() {
356
378
  return;
357
379
  }
358
380
  if (subcommand === "api-key") {
359
- await handleSetup(toolArgs ?? flags.apiKey);
381
+ await handleSetup(toolArgs ?? flags.apiKey, flags.local);
360
382
  return;
361
383
  }
362
384
  if (subcommand === "whoami") {
@@ -377,6 +399,7 @@ async function main() {
377
399
  console.log(`Email: ${info.email}`);
378
400
  console.log(`Org: ${info.organization_name} (${info.organization_id})`);
379
401
  }
402
+ console.error(`Config: ${getConfigPath()}`);
380
403
  return;
381
404
  }
382
405
  if (subcommand === "logout") {
@@ -495,6 +518,7 @@ async function main() {
495
518
  }
496
519
  }
497
520
  }
521
+ console.error(`Config: ${getConfigPath()}`);
498
522
  return;
499
523
  }
500
524
  // Ensure workspace is resolved for all remaining commands
@@ -5,10 +5,22 @@ export interface LoticsConfig {
5
5
  last_update_check?: number;
6
6
  latest_version?: string;
7
7
  }
8
- export declare function loadConfig(): LoticsConfig | null;
9
- export declare function saveConfig(config: LoticsConfig): void;
10
- export declare function deleteConfig(): void;
11
- export declare function getConfigPath(): string;
8
+ /**
9
+ * Where a config operation reads from or writes to.
10
+ *
11
+ * - `"auto"` — walk up from the current working directory; the first ancestor
12
+ * containing `.lotics/config.json` wins, else the global `~/.lotics`. This
13
+ * lets a project or worktree pin its own account and workspace, and keeps
14
+ * resolution stable when commands run from a subdirectory.
15
+ * - `"local"` — `.lotics/config.json` directly under the current working
16
+ * directory. Used to bootstrap a per-directory config (`lotics auth --local`)
17
+ * before any local file exists for `"auto"` to discover.
18
+ */
19
+ export type ConfigScope = "auto" | "local";
20
+ export declare function loadConfig(scope?: ConfigScope): LoticsConfig | null;
21
+ export declare function saveConfig(config: LoticsConfig, scope?: ConfigScope): void;
22
+ export declare function deleteConfig(scope?: ConfigScope): void;
23
+ export declare function getConfigPath(scope?: ConfigScope): string;
12
24
  /**
13
25
  * Check for a newer CLI version. Synchronous — prints a warning to stderr
14
26
  * if the cached latest version is newer than current. Kicks off a background
@@ -1,31 +1,59 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
- const CONFIG_DIR = path.join(os.homedir(), ".lotics");
5
- const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
6
- export function loadConfig() {
4
+ /**
5
+ * Resolve the config file for a scope.
6
+ *
7
+ * Resolution keys on the `config.json` *file*, never the `.lotics` directory:
8
+ * scaffolded custom-code apps already use `.lotics/` for generated types, so
9
+ * directory presence cannot signal a local config.
10
+ */
11
+ function configFileForScope(scope) {
12
+ if (scope === "local") {
13
+ return path.join(process.cwd(), ".lotics", "config.json");
14
+ }
15
+ let dir = process.cwd();
16
+ for (;;) {
17
+ const candidate = path.join(dir, ".lotics", "config.json");
18
+ if (fs.existsSync(candidate)) {
19
+ return candidate;
20
+ }
21
+ const parent = path.dirname(dir);
22
+ if (parent === dir) {
23
+ break;
24
+ }
25
+ dir = parent;
26
+ }
27
+ return path.join(os.homedir(), ".lotics", "config.json");
28
+ }
29
+ export function loadConfig(scope = "auto") {
7
30
  try {
8
- const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
31
+ const raw = fs.readFileSync(configFileForScope(scope), "utf-8");
9
32
  return JSON.parse(raw);
10
33
  }
11
34
  catch {
12
35
  return null;
13
36
  }
14
37
  }
15
- export function saveConfig(config) {
16
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
17
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf-8");
38
+ export function saveConfig(config, scope = "auto") {
39
+ const file = configFileForScope(scope);
40
+ // The config file holds an API key — keep it owner-only. `mode` on
41
+ // writeFileSync applies only when the file is created, so chmod after to
42
+ // also tighten a config written before this protection existed.
43
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
44
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
45
+ fs.chmodSync(file, 0o600);
18
46
  }
19
- export function deleteConfig() {
47
+ export function deleteConfig(scope = "auto") {
20
48
  try {
21
- fs.unlinkSync(CONFIG_FILE);
49
+ fs.unlinkSync(configFileForScope(scope));
22
50
  }
23
51
  catch {
24
52
  // Already deleted or never existed
25
53
  }
26
54
  }
27
- export function getConfigPath() {
28
- return CONFIG_FILE;
55
+ export function getConfigPath(scope = "auto") {
56
+ return configFileForScope(scope);
29
57
  }
30
58
  const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
31
59
  /**
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { loadConfig, saveConfig, deleteConfig, getConfigPath } from "./config.js";
6
+ /**
7
+ * Config resolution walks up from the current working directory: the first
8
+ * ancestor with a `.lotics/config.json` wins, otherwise the global
9
+ * `~/.lotics/config.json`. This lets a project or worktree pin its own
10
+ * account/workspace and keeps resolution stable when commands run from a
11
+ * subdirectory. Scope `"local"` targets the current directory exactly — used
12
+ * to bootstrap a per-directory config before any local file exists.
13
+ *
14
+ * These tests pin both `os.homedir()` and `process.cwd()` to temp dirs so the
15
+ * real home directory is never touched.
16
+ */
17
+ describe("config file resolution", () => {
18
+ let homeDir;
19
+ let projectDir;
20
+ let originalCwd;
21
+ beforeEach(() => {
22
+ originalCwd = process.cwd();
23
+ homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "lotics-home-"));
24
+ projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "lotics-project-"));
25
+ vi.spyOn(os, "homedir").mockReturnValue(homeDir);
26
+ process.chdir(projectDir);
27
+ });
28
+ afterEach(() => {
29
+ process.chdir(originalCwd);
30
+ vi.restoreAllMocks();
31
+ fs.rmSync(homeDir, { recursive: true, force: true });
32
+ fs.rmSync(projectDir, { recursive: true, force: true });
33
+ });
34
+ const globalFile = () => path.join(homeDir, ".lotics", "config.json");
35
+ const localFile = (dir = projectDir) => path.join(dir, ".lotics", "config.json");
36
+ function write(file, data) {
37
+ fs.mkdirSync(path.dirname(file), { recursive: true });
38
+ fs.writeFileSync(file, JSON.stringify(data));
39
+ }
40
+ function read(file) {
41
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
42
+ }
43
+ function chdirInto(...segments) {
44
+ const dir = path.join(projectDir, ...segments);
45
+ fs.mkdirSync(dir, { recursive: true });
46
+ process.chdir(dir);
47
+ return dir;
48
+ }
49
+ it("returns null when no config exists anywhere", () => {
50
+ expect(loadConfig()).toBeNull();
51
+ });
52
+ it("reads the global config when no local config exists", () => {
53
+ write(globalFile(), { api_key: "ltk_global" });
54
+ expect(loadConfig()?.api_key).toBe("ltk_global");
55
+ expect(getConfigPath()).toBe(globalFile());
56
+ });
57
+ it("prefers a .lotics/config.json in the current directory over the global one", () => {
58
+ write(globalFile(), { api_key: "ltk_global" });
59
+ write(localFile(), { api_key: "ltk_local" });
60
+ expect(loadConfig()?.api_key).toBe("ltk_local");
61
+ expect(getConfigPath()).toBe(localFile());
62
+ });
63
+ it("walks up to an ancestor's config when run from a subdirectory", () => {
64
+ write(localFile(), { api_key: "ltk_project_root" });
65
+ chdirInto("backend", "features");
66
+ expect(loadConfig()?.api_key).toBe("ltk_project_root");
67
+ expect(getConfigPath()).toBe(localFile());
68
+ });
69
+ it("auto saveConfig writes the resolved ancestor file, not the subdirectory", () => {
70
+ write(localFile(), { api_key: "ltk_old" });
71
+ const nested = chdirInto("sub");
72
+ saveConfig({ api_key: "ltk_new" });
73
+ expect(read(localFile()).api_key).toBe("ltk_new");
74
+ expect(fs.existsSync(localFile(nested))).toBe(false);
75
+ });
76
+ it("auto saveConfig writes the global config when no local config exists", () => {
77
+ saveConfig({ api_key: "ltk_new" });
78
+ expect(fs.existsSync(localFile())).toBe(false);
79
+ expect(read(globalFile()).api_key).toBe("ltk_new");
80
+ });
81
+ it("scope 'local' creates ./.lotics/config.json even when none existed", () => {
82
+ saveConfig({ api_key: "ltk_pinned" }, "local");
83
+ expect(read(localFile()).api_key).toBe("ltk_pinned");
84
+ expect(getConfigPath("local")).toBe(localFile());
85
+ });
86
+ it("saveConfig writes the credentials file owner-only (0600)", () => {
87
+ saveConfig({ api_key: "ltk_secret" }, "local");
88
+ expect(fs.statSync(localFile()).mode & 0o777).toBe(0o600);
89
+ });
90
+ it("saveConfig tightens an over-permissive pre-existing config file", () => {
91
+ write(localFile(), { api_key: "ltk_old" });
92
+ fs.chmodSync(localFile(), 0o644);
93
+ saveConfig({ api_key: "ltk_new" }, "local");
94
+ expect(fs.statSync(localFile()).mode & 0o777).toBe(0o600);
95
+ });
96
+ it("loadConfig('local') never inherits the global config", () => {
97
+ write(globalFile(), { api_key: "ltk_global" });
98
+ expect(loadConfig("local")).toBeNull();
99
+ });
100
+ it("loadConfig('local') ignores an ancestor's config — it reads the exact cwd only", () => {
101
+ write(localFile(), { api_key: "ltk_root" });
102
+ chdirInto("sub");
103
+ expect(loadConfig("local")).toBeNull();
104
+ });
105
+ it("deleteConfig removes the active local config, leaving the global one untouched", () => {
106
+ write(globalFile(), { api_key: "ltk_global" });
107
+ write(localFile(), { api_key: "ltk_local" });
108
+ deleteConfig();
109
+ expect(fs.existsSync(localFile())).toBe(false);
110
+ expect(fs.existsSync(globalFile())).toBe(true);
111
+ });
112
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.24.1",
3
+ "version": "0.25.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {