@tryarcanist/cli 0.1.158 → 0.1.160

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.
Files changed (3) hide show
  1. package/README.md +15 -1
  2. package/dist/index.js +102 -15
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -45,7 +45,7 @@ printf "arc_..." | arcanist auth login --token-stdin
45
45
  Auth and API URL precedence:
46
46
 
47
47
  ```text
48
- flag > environment > ~/.arcanist/config.json
48
+ flag > environment > project .arcanist-cli.json > ~/.arcanist/config.json
49
49
  ```
50
50
 
51
51
  Supported environment variables:
@@ -59,6 +59,20 @@ export ARCANIST_API_URL=https://api.tryarcanist.com
59
59
 
60
60
  Non-local API URLs must use HTTPS and must not embed credentials.
61
61
 
62
+ ### Project-local targeting
63
+
64
+ `arcanist auth login` writes only the global config at `~/.arcanist/config.json`.
65
+ In a git repo, the CLI also looks for `.arcanist-cli.json` from the current directory up to the git root.
66
+ The nearest file wins, files above the git root are ignored, and discovery is skipped outside git repos.
67
+
68
+ Project files are accepted only for loopback local development targets.
69
+ They must contain both `apiUrl` and `token`, and `apiUrl` must be `localhost`, `127.0.0.1`, or `::1`.
70
+ Malformed, incomplete, or non-loopback project files fail closed instead of falling through to the global config.
71
+ When a project file is active, env or flag overrides must provide both API URL and token together.
72
+
73
+ Arcanist development worktrees created with `bash scripts/worktree-setup.sh` create `.arcanist-cli.json` automatically.
74
+ From inside those worktrees, use `scripts/arc-prod <command>` to run one command against production using the existing global config.
75
+
62
76
  ## Global flags
63
77
 
64
78
  ```bash
package/dist/index.js CHANGED
@@ -140,11 +140,12 @@ async function apiFetchText(config, path, init) {
140
140
  }
141
141
 
142
142
  // src/config.ts
143
- import { mkdirSync, readFileSync, writeFileSync } from "fs";
143
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
144
144
  import { homedir } from "os";
145
- import { join } from "path";
145
+ import { dirname, join } from "path";
146
146
  var CONFIG_DIR = join(homedir(), ".arcanist");
147
147
  var CONFIG_FILE = join(CONFIG_DIR, "config.json");
148
+ var PROJECT_CONFIG_FILE = ".arcanist-cli.json";
148
149
  var DEFAULT_API_URL = "https://app.tryarcanist.com";
149
150
  function loadFileConfig() {
150
151
  try {
@@ -155,12 +156,19 @@ function loadFileConfig() {
155
156
  }
156
157
  function loadConfig(overrides = {}) {
157
158
  const fileConfig = loadFileConfig();
158
- const apiUrl = overrides.apiUrl ?? process.env.ARCANIST_API_URL ?? fileConfig?.apiUrl;
159
- const token = overrides.token ?? process.env.ARCANIST_TOKEN ?? fileConfig?.token;
159
+ const completeEnvOrFlagConfig = resolveCompleteEnvOrFlagConfig(overrides);
160
+ if (completeEnvOrFlagConfig) return validateAndNormalizeConfig(completeEnvOrFlagConfig);
161
+ const projectConfig = loadProjectConfig();
162
+ const envOrFlagConfig = resolveEnvOrFlagConfig(overrides, projectConfig !== null);
163
+ const apiUrl = envOrFlagConfig?.apiUrl ?? projectConfig?.apiUrl ?? fileConfig?.apiUrl;
164
+ const token = envOrFlagConfig?.token ?? projectConfig?.token ?? fileConfig?.token;
160
165
  if (!apiUrl || !token) return null;
161
- const urlError = validateApiUrl(apiUrl);
162
- if (urlError) throw new CliError("user", urlError);
163
- return { apiUrl: normalizeBaseUrl(apiUrl), token };
166
+ const config = validateAndNormalizeConfig({ apiUrl, token });
167
+ if (projectConfig && !envOrFlagConfig && !overrides.json) {
168
+ process.stderr.write(`using ${PROJECT_CONFIG_FILE} -> ${config.apiUrl}
169
+ `);
170
+ }
171
+ return config;
164
172
  }
165
173
  function saveConfig(config) {
166
174
  const urlError = validateApiUrl(config.apiUrl);
@@ -183,6 +191,19 @@ function resolveLoginApiUrl(apiUrl) {
183
191
  if (urlError) throw new CliError("user", urlError);
184
192
  return normalizeBaseUrl(raw);
185
193
  }
194
+ function loadProjectConfig(cwd = process.cwd()) {
195
+ const gitRoot = findGitRoot(cwd);
196
+ if (!gitRoot) return null;
197
+ let current = cwd;
198
+ while (true) {
199
+ const configPath = join(current, PROJECT_CONFIG_FILE);
200
+ if (existsSync(configPath)) return parseProjectConfig(configPath);
201
+ if (current === gitRoot) return null;
202
+ const parent = dirname(current);
203
+ if (parent === current) return null;
204
+ current = parent;
205
+ }
206
+ }
186
207
  function validateApiUrl(url) {
187
208
  let parsed;
188
209
  try {
@@ -194,12 +215,78 @@ function validateApiUrl(url) {
194
215
  return "API URL must not include embedded credentials. Use --token or ARCANIST_TOKEN to authenticate.";
195
216
  }
196
217
  const host = parsed.hostname.replace(/^\[|\]$/g, "");
197
- const isLocal = host === "localhost" || host === "127.0.0.1" || host === "::1";
198
- if (parsed.protocol !== "https:" && !isLocal) {
218
+ if (parsed.protocol !== "https:" && !isLoopbackHost(parsed)) {
199
219
  return "API URL must use HTTPS for non-local hosts";
200
220
  }
201
221
  return null;
202
222
  }
223
+ function resolveEnvOrFlagConfig(overrides, projectActive) {
224
+ const apiUrl = overrides.apiUrl ?? process.env.ARCANIST_API_URL;
225
+ const token = overrides.token ?? process.env.ARCANIST_TOKEN;
226
+ if (!projectActive) {
227
+ if (!apiUrl && !token) return null;
228
+ return { apiUrl, token };
229
+ }
230
+ if (apiUrl && !token || !apiUrl && token) {
231
+ throw new CliError(
232
+ "user",
233
+ `${PROJECT_CONFIG_FILE} is active; set both ARCANIST_API_URL and ARCANIST_TOKEN, pass both --api-url and --token, or set neither.`
234
+ );
235
+ }
236
+ if (!apiUrl || !token) return null;
237
+ return { apiUrl, token };
238
+ }
239
+ function resolveCompleteEnvOrFlagConfig(overrides) {
240
+ const apiUrl = overrides.apiUrl ?? process.env.ARCANIST_API_URL;
241
+ const token = overrides.token ?? process.env.ARCANIST_TOKEN;
242
+ if (!apiUrl || !token) return null;
243
+ return { apiUrl, token };
244
+ }
245
+ function validateAndNormalizeConfig(config) {
246
+ const urlError = validateApiUrl(config.apiUrl);
247
+ if (urlError) throw new CliError("user", urlError);
248
+ return { apiUrl: normalizeBaseUrl(config.apiUrl), token: config.token };
249
+ }
250
+ function findGitRoot(cwd) {
251
+ let current = cwd;
252
+ while (true) {
253
+ if (existsSync(join(current, ".git"))) return current;
254
+ const parent = dirname(current);
255
+ if (parent === current) return null;
256
+ current = parent;
257
+ }
258
+ }
259
+ function parseProjectConfig(configPath) {
260
+ let parsed;
261
+ try {
262
+ parsed = JSON.parse(readFileSync(configPath, "utf-8"));
263
+ } catch {
264
+ throw new CliError("user", `${PROJECT_CONFIG_FILE} is not valid JSON.`);
265
+ }
266
+ if (!parsed || typeof parsed !== "object") {
267
+ throw new CliError("user", `${PROJECT_CONFIG_FILE} must contain both apiUrl and token.`);
268
+ }
269
+ const config = parsed;
270
+ if (typeof config.apiUrl !== "string" || typeof config.token !== "string" || !config.apiUrl || !config.token) {
271
+ throw new CliError("user", `${PROJECT_CONFIG_FILE} must contain both apiUrl and token.`);
272
+ }
273
+ let url;
274
+ try {
275
+ url = new URL(config.apiUrl);
276
+ } catch {
277
+ throw new CliError("user", `${PROJECT_CONFIG_FILE} apiUrl must be a valid URL.`);
278
+ }
279
+ if (!isLoopbackHost(url)) {
280
+ throw new CliError("user", `${PROJECT_CONFIG_FILE} apiUrl must be a loopback host.`);
281
+ }
282
+ const urlError = validateApiUrl(config.apiUrl);
283
+ if (urlError) throw new CliError("user", urlError);
284
+ return { apiUrl: normalizeBaseUrl(config.apiUrl), token: config.token };
285
+ }
286
+ function isLoopbackHost(parsed) {
287
+ const host = parsed.hostname.replace(/^\[|\]$/g, "");
288
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
289
+ }
203
290
 
204
291
  // src/runtime.ts
205
292
  import { randomUUID } from "crypto";
@@ -3056,8 +3143,8 @@ async function messageCommand(sessionId, promptArg, options = {}, command) {
3056
3143
 
3057
3144
  // src/commands/sandbox.ts
3058
3145
  import { execFileSync } from "child_process";
3059
- import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
3060
- import { dirname } from "path";
3146
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
3147
+ import { dirname as dirname2 } from "path";
3061
3148
 
3062
3149
  // ../../shared/sandbox-layer/parser.ts
3063
3150
  import { isMap, isScalar, isSeq, parseDocument } from "yaml";
@@ -3616,7 +3703,7 @@ function assertCleanAndPushed() {
3616
3703
  throw new CliError("user", "Current commit is not pushed; push before building the sandbox layer.");
3617
3704
  }
3618
3705
  function assertManifestExists(path) {
3619
- if (!existsSync(path)) throw new CliError("user", `Missing sandbox manifest: ${path}`);
3706
+ if (!existsSync2(path)) throw new CliError("user", `Missing sandbox manifest: ${path}`);
3620
3707
  }
3621
3708
  function parseRepoArg2(value, fallback) {
3622
3709
  if (!value) return fallback();
@@ -3838,10 +3925,10 @@ function sourceBody(sourceRepo, manifestPath) {
3838
3925
  }
3839
3926
  async function sandboxInitCommand(options = {}, command) {
3840
3927
  const runtime = getRuntimeOptions(command, options);
3841
- if (existsSync(DEFAULT_MANIFEST_PATH)) throw new CliError("conflict", `${DEFAULT_MANIFEST_PATH} already exists`);
3928
+ if (existsSync2(DEFAULT_MANIFEST_PATH)) throw new CliError("conflict", `${DEFAULT_MANIFEST_PATH} already exists`);
3842
3929
  const layerPath = ".arcanist/sandbox.layer.Dockerfile";
3843
- if (existsSync(layerPath)) throw new CliError("conflict", `${layerPath} already exists`);
3844
- mkdirSync2(dirname(DEFAULT_MANIFEST_PATH), { recursive: true });
3930
+ if (existsSync2(layerPath)) throw new CliError("conflict", `${layerPath} already exists`);
3931
+ mkdirSync2(dirname2(DEFAULT_MANIFEST_PATH), { recursive: true });
3845
3932
  writeFileSync2(
3846
3933
  DEFAULT_MANIFEST_PATH,
3847
3934
  [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tryarcanist/cli",
3
- "version": "0.1.158",
3
+ "version": "0.1.160",
4
4
  "description": "CLI for Arcanist — create and manage coding agent sessions",
5
5
  "type": "module",
6
6
  "bin": {