railcode 0.1.25 → 0.1.27

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
@@ -19,7 +19,9 @@ on your `PATH`.
19
19
  ```
20
20
  railcode login [--api-url <url>] Sign in and mint a personal API token
21
21
  railcode login --setup-token <token> Sign in with a one-time onboarding setup token
22
- railcode init <app> [--template react|static]
22
+ railcode init <app> [dir] [--template react|static] [--force]
23
+ Scaffold an app. dir defaults to ./<app>;
24
+ pass "." to scaffold into the current dir
23
25
  railcode deploy Build (if configured) and deploy the app here
24
26
  railcode db <list|query> ... List data connectors / run read-only SQL
25
27
  railcode connections <list|create|delete> ... Manage org data connectors (admin)
@@ -54,7 +56,11 @@ your saved org and are gated server-side by the same capabilities as the console
54
56
  a personal API token: the CLI exchanges it once for a personal API token and
55
57
  writes the same config as a browser login. If it's expired or already used,
56
58
  generate a fresh prompt from the dashboard.
57
- - **`init <app>`** scaffolds a directory. By default it creates a React + Vite +
59
+ - **`init <app> [dir]`** scaffolds a directory. `dir` is optional and defaults to
60
+ `./<app>`; pass `.` to scaffold into the current directory, or any path to
61
+ scaffold into it. The target need not be empty (same-named files are
62
+ overwritten), but an existing `railcode.json` is preserved unless you pass
63
+ `--force`. By default it creates a React + Vite +
58
64
  Zustand starter — a personalized "Welcome to Railcode, `<name>`" home wired to
59
65
  every SDK capability: identity/team, a live to-dos list and file uploads (work
60
66
  with zero setup), and SQL, saved queries, service connectors, and LLM cards that
@@ -80,23 +86,38 @@ your saved org and are gated server-side by the same capabilities as the console
80
86
  (SQL is never interpolated), `--file <path>` reads SQL from a file, and
81
87
  `--json` prints the raw `{ columns, rows, rowcount, truncated }` envelope.
82
88
  - **`agent`** manages org-scoped managed agents when your permissions allow it.
83
- Manifests are JSON files in the same shape the API stores under
89
+ Manifests are JSON or YAML files in the same shape the API stores under
84
90
  `agent.manifest`.
85
91
  - `railcode agent list` lists agents; `--json` prints raw `AgentOut[]`.
86
92
  - `railcode agent show <name|uuid>` prints one agent; `--manifest` prints only
87
93
  its manifest JSON.
88
94
  - `railcode agent pull <name|uuid> --output agent.json` writes the existing
89
95
  manifest for editing.
90
- - `railcode agent create --file agent.json` creates an agent; `update <agent>
91
- --file agent.json` replaces an existing agent manifest; `delete <agent> --yes`
92
- archives it.
93
- - `railcode agent test --file agent.json --input '{"k":"v"}'` runs a draft
96
+ - `railcode agent create --file agent.yaml` creates an agent; `update <agent>
97
+ --file agent.yaml` replaces an existing agent manifest; `delete <agent> --yes`
98
+ archives it. `--visibility <org|personal>` on `create`/`update`/`test` sets or
99
+ changes who the agent belongs to (default `org` on create; omitted on `update`
100
+ leaves it alone). A `personal` agent is invokable and manageable by its
101
+ creator alone — no grant makes it shared — and `personal -> org` is blocked
102
+ once created. Requires `agent:create` (personal) or `agent:create_org` (org).
103
+ `agent show`/`list` print `visibility`, and `show` prints `Owner:` for a
104
+ personal agent.
105
+ - `railcode agent test --file agent.yaml --input '{"k":"v"}'` runs a draft
94
106
  manifest without saving; `railcode agent run <agent> --input '{"k":"v"}'`
95
107
  invokes a saved agent and prints the result. Use `--trace` for the step trace
96
108
  and `--json` for the raw run detail.
97
109
  - `railcode agent schedule set <agent> --cron "0 9 * * *" --timezone UTC`
98
110
  creates or updates the agent's one schedule. `schedule show`, `pause`,
99
111
  `resume`, `delete --yes`, and `run-now` operate on that single schedule.
112
+ - **`personal-connectors`** (aliases `personal-connector`, `pc`) manages **your
113
+ own** connected third-party accounts (Gmail, Slack, …), brokered by railcode (in-house) —
114
+ distinct from org-admin **service connectors** and from an agent's
115
+ `tools.personal_connectors`. `list` shows toolkits this deployment brokers and
116
+ your connection status; `tools <toolkit>` prints that toolkit's callable tools
117
+ and input schemas; `connect <toolkit>` prints an OAuth URL to open in a
118
+ browser; `call <toolkit> <tool> [--args '<json>']` runs one tool on your own
119
+ connected account. Hits the non-org-scoped `/api/personal-connections/*` plane
120
+ — a personal connection belongs to the human, not the org.
100
121
 
101
122
  ## Configuration
102
123
 
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { dirname, join, relative, resolve, sep } from "node:path";
10
10
  import process from "node:process";
11
11
  import { createInterface } from "node:readline/promises";
12
12
  import { fileURLToPath } from "node:url";
13
+ import { parse as parseYaml } from "yaml";
13
14
  import { KvQueryError, kvCount, kvQuery, } from "./dev/kv-query.js";
14
15
  import { DbError, formatTable, inferEngine, parseEngine, parseSqlParams, pickSqlSource, } from "./db.js";
15
16
  import { QueryCmdError, paramSignature, parseParamDecls, parseRunParams, pickAuthoringSql, } from "./query.js";
@@ -35,11 +36,13 @@ const HELP = `railcode ${VERSION}
35
36
  Developer CLI for the multi-tenant Railcode platform.
36
37
 
37
38
  Usage:
38
- railcode login [--api-url <url>] Sign in and mint a personal API token
39
+ railcode login [--api-url <url>] [--paste] Sign in and mint a personal API token
40
+ (--paste / --no-browser: skip the localhost
41
+ callback, paste a code instead — for SSH/headless)
39
42
  railcode login --setup-token <token> Sign in with a one-time onboarding setup token
40
43
  railcode deploy [--private] Build (if configured) and deploy the app here
41
44
  railcode dev [--port <n>] [--reset] Run the app locally against an emulated /_api
42
- railcode init <app> [--template react|static]
45
+ railcode init <app> [dir] [--template react|static] [--force]
43
46
  railcode design-system Print your org's design-system guidance (markdown)
44
47
  railcode db <list|query> ... List data connectors / run read-only SQL
45
48
  railcode query <list|run|create|update|delete> ... Saved queries: invoke by name / author (admin)
@@ -63,7 +66,8 @@ Usage:
63
66
  railcode --help
64
67
 
65
68
  Config: ~/.railcode/config.json
66
- API URL resolves from --api-url, then RAILCODE_API_URL, then saved config.
69
+ API URL resolves from --api-url, then RAILCODE_API_URL, then saved config, then
70
+ the default (${DEFAULT_API_URL}) — never prompted.
67
71
  `;
68
72
  // ---------------------------------------------------------------------------
69
73
  // Entry point
@@ -212,9 +216,12 @@ async function commandLogin(args) {
212
216
  await loginWithSetupToken(args, saved, setupToken);
213
217
  return;
214
218
  }
215
- const apiUrl = await resolveLoginApiUrl(args, saved);
219
+ const apiUrl = resolveLoginApiUrl(args, saved);
220
+ // --paste / --no-browser: skip the localhost callback entirely and go straight
221
+ // to "open this URL, paste the code" — the right default for headless/SSH.
222
+ const pasteMode = args.options.paste === true || args.options.noBrowser === true;
216
223
  const serverConfig = (await readJsonOrThrow(await apiFetch(apiUrl, "/api/config", {}), "Server config"));
217
- const minted = await authorizeCliWithBrowser(apiUrl);
224
+ const minted = await authorizeCliWithBrowser(apiUrl, pasteMode);
218
225
  // Resolve the caller's identity + organization.
219
226
  const me = (await readJsonOrThrow(await apiFetch(apiUrl, "/api/auth/me", {
220
227
  headers: { Authorization: `Bearer ${minted.token}` },
@@ -282,10 +289,16 @@ async function loginWithSetupToken(args, saved, setupToken) {
282
289
  }
283
290
  console.log(`Token ${redeemed.token_prefix}... saved to ${CONFIG_PATH}.`);
284
291
  }
285
- async function authorizeCliWithBrowser(apiUrl) {
292
+ async function authorizeCliWithBrowser(apiUrl, pasteMode) {
286
293
  if (!process.stdin.isTTY) {
287
294
  throw new CliError("Browser login requires a TTY. Set RAILCODE_API_TOKEN in non-interactive environments.", 2);
288
295
  }
296
+ if (pasteMode) {
297
+ return authorizeCliByPaste(apiUrl);
298
+ }
299
+ // Happy path: a localhost callback server catches the code automatically. We
300
+ // also print a code-mode URL so login still completes when the browser is on a
301
+ // different machine and can't reach this box's localhost (the common SSH case).
289
302
  const state = randomBytes(16).toString("hex");
290
303
  const callback = await startCliAuthCallback(state);
291
304
  try {
@@ -293,23 +306,80 @@ async function authorizeCliWithBrowser(apiUrl) {
293
306
  redirect_uri: callback.redirectUri,
294
307
  state,
295
308
  })}`;
296
- console.log("");
297
- printUrlBox("Railcode login", authorizeUrl);
309
+ const manualUrl = `${apiUrl}/api/auth/cli/authorize?${new URLSearchParams({ mode: "code" })}`;
298
310
  console.log("");
299
311
  if (openBrowser(authorizeUrl)) {
300
- console.log("Opening your browser. If it does not open, paste the URL above.");
312
+ console.log("Opening your browser to authorize Railcode.");
301
313
  }
302
314
  else {
303
- console.log("Paste the URL above into your browser to continue.");
315
+ console.log("Open this URL in your browser to authorize Railcode:");
316
+ console.log(` ${authorizeUrl}`);
304
317
  }
305
- console.log("Waiting for browser authorization...");
306
- const code = await callback.code;
318
+ console.log("");
319
+ console.log("On a remote/SSH box? If the browser can't reach this machine, open the");
320
+ console.log("URL below to get a code, then paste it here:");
321
+ console.log("");
322
+ printUrlBox("Railcode login", manualUrl);
323
+ console.log("");
324
+ const code = await waitForCallbackOrPastedCode(callback.code);
307
325
  return await exchangeCliAuthCode(apiUrl, code);
308
326
  }
309
327
  finally {
310
328
  await callback.close();
311
329
  }
312
330
  }
331
+ // Paste-only flow (--paste / --no-browser): no localhost server, no waiting on a
332
+ // callback. Show the code-mode URL and read the pasted code back.
333
+ async function authorizeCliByPaste(apiUrl) {
334
+ const manualUrl = `${apiUrl}/api/auth/cli/authorize?${new URLSearchParams({ mode: "code" })}`;
335
+ console.log("");
336
+ printUrlBox("Railcode login", manualUrl);
337
+ console.log("");
338
+ if (openBrowser(manualUrl)) {
339
+ console.log("Opening your browser. If it does not open, paste the URL above.");
340
+ }
341
+ else {
342
+ console.log("Paste the URL above into your browser to continue.");
343
+ }
344
+ console.log("");
345
+ const code = (await prompt("Paste the code from your browser: ")).trim();
346
+ if (!code) {
347
+ throw new CliError("No code entered.", 1);
348
+ }
349
+ return exchangeCliAuthCode(apiUrl, code);
350
+ }
351
+ // Race the localhost callback against a pasted line — whichever yields a code
352
+ // first wins. An empty line means "keep waiting", so we re-prompt. When the
353
+ // callback wins, the abort signal cancels the pending readline question.
354
+ async function waitForCallbackOrPastedCode(callbackCode) {
355
+ const controller = new AbortController();
356
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
357
+ const fromCallback = callbackCode.then((code) => {
358
+ controller.abort();
359
+ return code;
360
+ }, (error) => {
361
+ controller.abort();
362
+ throw error;
363
+ });
364
+ const fromPaste = (async () => {
365
+ for (;;) {
366
+ const answer = (await rl.question("Paste the code from your browser (or press Enter to keep waiting): ", {
367
+ signal: controller.signal,
368
+ })).trim();
369
+ if (answer)
370
+ return answer;
371
+ }
372
+ })();
373
+ try {
374
+ return await Promise.race([fromCallback, fromPaste]);
375
+ }
376
+ finally {
377
+ controller.abort();
378
+ rl.close();
379
+ fromCallback.catch(() => { });
380
+ fromPaste.catch(() => { });
381
+ }
382
+ }
313
383
  async function startCliAuthCallback(expectedState) {
314
384
  let resolveCode = () => { };
315
385
  let rejectCode = () => { };
@@ -443,13 +513,12 @@ function escapeHtml(value) {
443
513
  .replaceAll('"', "&quot;")
444
514
  .replaceAll("'", "&#39;");
445
515
  }
446
- async function resolveLoginApiUrl(args, saved) {
447
- const explicit = optionApiUrl(args) || process.env.RAILCODE_API_URL;
448
- if (explicit)
449
- return normalizeApiOrigin(explicit);
450
- const fallback = saved.apiUrl || DEFAULT_API_URL;
451
- const answer = await prompt(`Server URL [${fallback}]: `);
452
- return normalizeApiOrigin(answer.trim() || fallback);
516
+ // Resolve the API URL non-interactively — no prompt. Precedence:
517
+ // --api-url flag RAILCODE_API_URL env → saved config → DEFAULT_API_URL.
518
+ // The default "just works"; override it only with --api-url (env/saved kept for
519
+ // continuity). Mirrors the setup-token path's resolution in loginWithSetupToken.
520
+ function resolveLoginApiUrl(args, saved) {
521
+ return normalizeApiOrigin(optionApiUrl(args) || process.env.RAILCODE_API_URL || saved.apiUrl || DEFAULT_API_URL);
453
522
  }
454
523
  // ---------------------------------------------------------------------------
455
524
  // deploy
@@ -700,12 +769,19 @@ function packageBuildCommand(root) {
700
769
  async function commandInit(args) {
701
770
  const app = args.rest[0];
702
771
  if (!app)
703
- throw new CliError("Usage: railcode init <app>", 2);
772
+ throw new CliError("Usage: railcode init <app> [dir]", 2);
704
773
  assertAppName(app);
705
774
  const template = initTemplate(args);
706
- const target = resolve(process.cwd(), app);
707
- if (existsSync(target) && readdirSync(target).length > 0) {
708
- throw new CliError(`Refusing to scaffold into a non-empty directory: ${target}`, 1);
775
+ // Optional second positional: the directory to scaffold into. Defaults to a
776
+ // new "<app>/" subdir. Passing "." scaffolds into the current directory. The
777
+ // target need not be empty same-named files are overwritten.
778
+ const dir = args.rest[1];
779
+ const target = resolve(process.cwd(), dir ?? app);
780
+ // Light-touch guard: never clobber an existing app manifest without --force.
781
+ const manifestPath = join(target, MANIFEST_NAME);
782
+ if (existsSync(manifestPath) && !args.options.force) {
783
+ throw new CliError(`A ${MANIFEST_NAME} already exists in ${relativeLabel(process.cwd(), target)}/. ` +
784
+ "Re-run with --force to overwrite it.", 1);
709
785
  }
710
786
  mkdirSync(target, { recursive: true });
711
787
  if (template === "static") {
@@ -716,7 +792,10 @@ async function commandInit(args) {
716
792
  }
717
793
  console.log(`Created ${template} Railcode app "${app}" in ${relativeLabel(process.cwd(), target)}/`);
718
794
  console.log("Next:");
719
- console.log(` cd ${app}`);
795
+ // Only prompt a `cd` when the files landed somewhere other than the cwd.
796
+ if (resolve(target) !== resolve(process.cwd())) {
797
+ console.log(` cd ${relativeLabel(process.cwd(), target)}`);
798
+ }
720
799
  if (template === "react") {
721
800
  console.log(" npm install");
722
801
  }
@@ -970,7 +1049,6 @@ type LlmOptions = {
970
1049
  provider?: string;
971
1050
  system?: string;
972
1051
  output?: LlmOutputSpec;
973
- temperature?: number;
974
1052
  maxOutputTokens?: number;
975
1053
  metadata?: Record<string, unknown>;
976
1054
  };
@@ -3794,8 +3872,8 @@ Usage:
3794
3872
  railcode agent show <agent> Show one agent
3795
3873
  railcode agent pull <agent> [--output <path>]
3796
3874
  Write the agent manifest as JSON
3797
- railcode agent create --file <path> Create an agent from a JSON manifest
3798
- railcode agent update <agent> --file <path> Update an agent from a JSON manifest
3875
+ railcode agent create --file <path> Create an agent from a JSON or YAML manifest
3876
+ railcode agent update <agent> --file <path> Update an agent from a JSON or YAML manifest
3799
3877
  railcode agent delete <agent> [--yes] Delete/archive an agent
3800
3878
  railcode agent test --file <path> [opts] Run a draft manifest without saving
3801
3879
  railcode agent run <agent> [opts] Trigger a saved agent and print results
@@ -3803,12 +3881,18 @@ Usage:
3803
3881
 
3804
3882
  Agent options:
3805
3883
  --json Print the raw API response
3806
- --file <path> JSON manifest file
3884
+ --file <path> JSON or YAML manifest file
3807
3885
  --output <path> Output path for pull (default stdout)
3808
3886
  --input '<json>' Invoke/test input JSON (default null)
3809
3887
  --input-file <path> Read invoke/test input JSON from a file
3810
3888
  --trace Include the run step trace in human output
3811
3889
  --yes Confirm delete without a prompt
3890
+ --visibility <org|personal> create/update/test only (default: org). A personal
3891
+ agent is owned/invoked by its creator alone — no
3892
+ grant makes it shared, and "personal -> org" is
3893
+ blocked once created. Requires the matching
3894
+ capability: agent:create for personal,
3895
+ agent:create_org for org.
3812
3896
 
3813
3897
  Schedule options:
3814
3898
  --name <name> Schedule name (default: default on create)
@@ -3817,7 +3901,7 @@ Schedule options:
3817
3901
  --timezone <iana> IANA timezone (default: UTC on create)
3818
3902
  --enabled / --disabled Initial or updated enabled state
3819
3903
 
3820
- Manifests are JSON files in the same shape the API stores under agent.manifest.
3904
+ Manifests are JSON or YAML files in the same shape the API stores under agent.manifest.
3821
3905
  Schedules are currently one per agent, so "set" creates the schedule if missing
3822
3906
  or updates the existing one.
3823
3907
  `;
@@ -3881,9 +3965,10 @@ async function commandAgentList(args) {
3881
3965
  console.log("No managed agents in this organization.");
3882
3966
  return;
3883
3967
  }
3884
- console.log(formatTable(["name", "model", "uuid", "updated"], agents.map((agent) => [
3968
+ console.log(formatTable(["name", "model", "visibility", "uuid", "updated"], agents.map((agent) => [
3885
3969
  agent.name,
3886
3970
  typeof agent.manifest.model === "string" ? agent.manifest.model : "-",
3971
+ agent.visibility,
3887
3972
  agent.uuid,
3888
3973
  shortDate(agent.updated_at),
3889
3974
  ])));
@@ -3918,13 +4003,14 @@ async function commandAgentPull(args) {
3918
4003
  process.stdout.write(manifest);
3919
4004
  }
3920
4005
  async function commandAgentCreate(args) {
3921
- assertKnownAgentOptions(args, ["file", "json", "apiUrl"]);
4006
+ assertKnownAgentOptions(args, ["file", "json", "visibility", "apiUrl"]);
3922
4007
  const manifest = readAgentManifest(args);
4008
+ const visibility = agentVisibilityOption(args);
3923
4009
  const config = requireLogin(args);
3924
4010
  const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/agents`, {
3925
4011
  method: "POST",
3926
4012
  headers: { "Content-Type": "application/json" },
3927
- body: JSON.stringify({ manifest }),
4013
+ body: JSON.stringify({ manifest, ...(visibility !== undefined ? { visibility } : {}) }),
3928
4014
  });
3929
4015
  if (resp.status === 401)
3930
4016
  await reLoginOrThrow();
@@ -3932,14 +4018,15 @@ async function commandAgentCreate(args) {
3932
4018
  printAgentMutation(result, args.options.json === true, "created");
3933
4019
  }
3934
4020
  async function commandAgentUpdate(args) {
3935
- assertKnownAgentOptions(args, ["file", "json", "apiUrl"]);
4021
+ assertKnownAgentOptions(args, ["file", "json", "visibility", "apiUrl"]);
3936
4022
  const ref = agentArg(args, "update");
3937
4023
  const manifest = readAgentManifest(args);
4024
+ const visibility = agentVisibilityOption(args);
3938
4025
  const config = requireLogin(args);
3939
4026
  const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/agents/${encodeURIComponent(ref)}`, {
3940
4027
  method: "PUT",
3941
4028
  headers: { "Content-Type": "application/json" },
3942
- body: JSON.stringify({ manifest }),
4029
+ body: JSON.stringify({ manifest, ...(visibility !== undefined ? { visibility } : {}) }),
3943
4030
  });
3944
4031
  if (resp.status === 401)
3945
4032
  await reLoginOrThrow();
@@ -3968,14 +4055,15 @@ async function commandAgentDelete(args) {
3968
4055
  console.log(`Agent "${ref}" deleted.`);
3969
4056
  }
3970
4057
  async function commandAgentTest(args) {
3971
- assertKnownAgentOptions(args, ["file", "input", "inputFile", "json", "trace", "apiUrl"]);
4058
+ assertKnownAgentOptions(args, ["file", "input", "inputFile", "json", "trace", "visibility", "apiUrl"]);
3972
4059
  const manifest = readAgentManifest(args);
3973
4060
  const input = readJsonInput(args);
4061
+ const visibility = agentVisibilityOption(args);
3974
4062
  const config = requireLogin(args);
3975
4063
  const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/agents/test`, {
3976
4064
  method: "POST",
3977
4065
  headers: { "Content-Type": "application/json" },
3978
- body: JSON.stringify({ manifest, input }),
4066
+ body: JSON.stringify({ manifest, input, ...(visibility !== undefined ? { visibility } : {}) }),
3979
4067
  });
3980
4068
  if (resp.status === 401)
3981
4069
  await reLoginOrThrow();
@@ -4175,22 +4263,38 @@ async function requireSingleSchedule(config, ref) {
4175
4263
  function readAgentManifest(args) {
4176
4264
  const path = optionString(args, "file");
4177
4265
  if (!path)
4178
- throw new CliError("Pass --file <agent-manifest.json>.", 2);
4179
- return readJsonObjectFile(path, "Agent manifest");
4266
+ throw new CliError("Pass --file <agent-manifest.json|yaml|yml>.", 2);
4267
+ const format = /\.ya?ml$/i.test(path) ? "YAML" : "JSON";
4268
+ return readObjectFile(path, "Agent manifest", format);
4269
+ }
4270
+ // Omitted means "use the server default" on create/test (org) and "leave alone"
4271
+ // on update — never send the key unless the caller passed it explicitly.
4272
+ function agentVisibilityOption(args) {
4273
+ const value = optionString(args, "visibility");
4274
+ if (value === undefined)
4275
+ return undefined;
4276
+ if (value !== "org" && value !== "personal") {
4277
+ throw new CliError(`Invalid --visibility: ${value} (expected "org" or "personal").`, 2);
4278
+ }
4279
+ return value;
4180
4280
  }
4181
4281
  function readJsonObjectFile(path, label) {
4282
+ return readObjectFile(path, label, "JSON");
4283
+ }
4284
+ function readObjectFile(path, label, format) {
4182
4285
  const resolved = resolve(process.cwd(), path);
4183
4286
  if (!existsSync(resolved))
4184
4287
  throw new CliError(`${label} file not found: ${path}`, 2);
4185
4288
  let parsed;
4186
4289
  try {
4187
- parsed = JSON.parse(readFileSync(resolved, "utf8"));
4290
+ const raw = readFileSync(resolved, "utf8");
4291
+ parsed = format === "YAML" ? parseYaml(raw) : JSON.parse(raw);
4188
4292
  }
4189
4293
  catch (error) {
4190
- throw new CliError(`${label} file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, 2);
4294
+ throw new CliError(`${label} file is not valid ${format}: ${error instanceof Error ? error.message : String(error)}`, 2);
4191
4295
  }
4192
4296
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
4193
- throw new CliError(`${label} file must contain a JSON object.`, 2);
4297
+ throw new CliError(`${label} file must contain a ${format} object.`, 2);
4194
4298
  }
4195
4299
  return parsed;
4196
4300
  }
@@ -4264,6 +4368,7 @@ function printAgentMutation(result, json, verb) {
4264
4368
  }
4265
4369
  console.log(`Agent "${result.agent.name}" ${verb}.`);
4266
4370
  console.log(`UUID: ${result.agent.uuid}`);
4371
+ console.log(`Visibility: ${result.agent.visibility}`);
4267
4372
  if (result.warnings.length > 0) {
4268
4373
  console.log("");
4269
4374
  console.log("Warnings:");
@@ -4276,6 +4381,9 @@ function printAgent(agent) {
4276
4381
  console.log(`UUID: ${agent.uuid}`);
4277
4382
  console.log(`Description: ${agent.description || "-"}`);
4278
4383
  console.log(`Model: ${typeof agent.manifest.model === "string" ? agent.manifest.model : "-"}`);
4384
+ console.log(`Visibility: ${agent.visibility}`);
4385
+ if (agent.visibility === "personal")
4386
+ console.log(`Owner: ${agent.owner_uuid ?? "-"}`);
4279
4387
  console.log(`Updated: ${agent.updated_at}`);
4280
4388
  console.log(`Content hash: ${agent.content_hash}`);
4281
4389
  }
@@ -5123,17 +5231,20 @@ const PC_HELP = `railcode personal-connectors — your own connected accounts (G
5123
5231
  Usage:
5124
5232
  railcode personal-connectors list Toolkits this deployment brokers + your status
5125
5233
  railcode personal-connectors tools <toolkit> The callable tools of a toolkit (and their inputs)
5126
- railcode personal-connectors connect <toolkit> Print the OAuth URL to connect it (open in a browser)
5127
- railcode personal-connectors call <tool> [--args '<json>'] Run one tool on your own account
5234
+ railcode personal-connectors connect <toolkit> Start connecting it: prints the OAuth URL, or
5235
+ reports if it needs a token
5236
+ railcode personal-connectors connect <toolkit> --token '<value>' Link a token-auth connector (e.g. a GitHub PAT)
5237
+ railcode personal-connectors call <toolkit> <tool> [--args '<json>'] Run one tool on your own account
5128
5238
 
5129
5239
  Aliases: personal-connector, pc
5130
5240
 
5131
5241
  Discovery is what you build against: \`tools <toolkit> --json\` shows each tool's
5132
5242
  input schema, so you know what to put in an app's \`personal_connectors:\` and what
5133
- \`personalConnections.call('<tool>', {...})\` expects.
5243
+ \`personalConnections.call('<toolkit>', '<tool>', {...})\` expects.
5134
5244
 
5135
5245
  Options:
5136
5246
  --json Print raw JSON instead of a table
5247
+ --token '<value>' Token/key for \`connect\` on a token-auth connector
5137
5248
  --args '<json>' Arguments object for \`call\` (default {})
5138
5249
  `;
5139
5250
  async function commandPersonalConnectors(args) {
@@ -5212,13 +5323,31 @@ async function commandPcTools(args) {
5212
5323
  console.log(`\n${tools.length} tool(s). Add \`--json\` to see each tool's input schema.`);
5213
5324
  }
5214
5325
  async function commandPcConnect(args) {
5215
- rejectUnknownOptions(args, ["json", "apiUrl"], "personal-connectors connect", PC_HELP);
5326
+ rejectUnknownOptions(args, ["json", "token", "apiUrl"], "personal-connectors connect", PC_HELP);
5216
5327
  const toolkit = args.rest[1];
5217
5328
  if (!toolkit)
5218
5329
  throw new CliError("Usage: railcode personal-connectors connect <toolkit>", 2);
5219
5330
  const config = requireLogin(args);
5220
- // Connecting is browser-bound OAuth; the CLI can't complete it. It prints the
5221
- // URL and leaves opening it to you — the same handoff `railcode login` uses.
5331
+ // A token-auth connector (e.g. a GitHub PAT): `--token <value>` submits it
5332
+ // directly, otherwise we tell the caller a token is needed.
5333
+ if (typeof args.options.token === "string") {
5334
+ const tokResp = await authedFetch(config, `/api/personal-connections/${encodeURIComponent(toolkit)}/token`, {
5335
+ method: "POST",
5336
+ headers: { "Content-Type": "application/json" },
5337
+ body: JSON.stringify({ token: args.options.token }),
5338
+ });
5339
+ if (tokResp.status === 401)
5340
+ await reLoginOrThrow();
5341
+ if (pcUnavailable(tokResp.status)) {
5342
+ console.log("Personal connectors are not enabled on this deployment.");
5343
+ return;
5344
+ }
5345
+ await readJsonOrThrow(tokResp, `Connect ${toolkit}`);
5346
+ console.log(`Connected ${toolkit}.`);
5347
+ return;
5348
+ }
5349
+ // Otherwise start the flow. OAuth is browser-bound (the CLI can't complete it),
5350
+ // so it prints the URL; a token connector reports that a token is required.
5222
5351
  const resp = await authedFetch(config, `/api/personal-connections/${encodeURIComponent(toolkit)}/connect`, { method: "POST" });
5223
5352
  if (resp.status === 401)
5224
5353
  await reLoginOrThrow();
@@ -5231,13 +5360,21 @@ async function commandPcConnect(args) {
5231
5360
  console.log(JSON.stringify(body, null, 2));
5232
5361
  return;
5233
5362
  }
5363
+ if (body.mode === "token") {
5364
+ console.log(`${toolkit} connects with a ${body.token_label ?? "token"}. ` +
5365
+ `Re-run with --token '<value>' to link it:\n\n railcode personal-connectors connect ${toolkit} --token '<value>'\n`);
5366
+ return;
5367
+ }
5234
5368
  console.log(`Open this URL to connect ${toolkit}, then re-run your command:\n\n ${body.redirect_url}\n`);
5235
5369
  }
5236
5370
  async function commandPcCall(args) {
5237
5371
  rejectUnknownOptions(args, ["json", "args", "apiUrl"], "personal-connectors call", PC_HELP);
5238
- const tool = args.rest[1];
5239
- if (!tool)
5240
- throw new CliError("Usage: railcode personal-connectors call <tool> [--args '<json>']", 2);
5372
+ // Scoped by connector: name the toolkit AND the tool.
5373
+ const toolkit = args.rest[1];
5374
+ const tool = args.rest[2];
5375
+ if (!toolkit || !tool) {
5376
+ throw new CliError("Usage: railcode personal-connectors call <toolkit> <tool> [--args '<json>']", 2);
5377
+ }
5241
5378
  let toolArgs = {};
5242
5379
  if (typeof args.options.args === "string") {
5243
5380
  try {
@@ -5251,7 +5388,7 @@ async function commandPcCall(args) {
5251
5388
  const resp = await authedFetch(config, `/api/personal-connections/call`, {
5252
5389
  method: "POST",
5253
5390
  headers: { "Content-Type": "application/json" },
5254
- body: JSON.stringify({ tool, arguments: toolArgs }),
5391
+ body: JSON.stringify({ toolkit, tool, arguments: toolArgs }),
5255
5392
  });
5256
5393
  if (resp.status === 401)
5257
5394
  await reLoginOrThrow();
@@ -5264,7 +5401,7 @@ async function commandPcCall(args) {
5264
5401
  if (resp.status === 409) {
5265
5402
  throw new CliError(await errorDetail(resp), 1);
5266
5403
  }
5267
- const body = await readJsonOrThrow(resp, `Call ${tool}`);
5404
+ const body = await readJsonOrThrow(resp, `Call ${toolkit}/${tool}`);
5268
5405
  console.log(JSON.stringify(body, null, 2));
5269
5406
  }
5270
5407
  // ---------------------------------------------------------------------------
@@ -7286,17 +7423,6 @@ async function pcForward(ctx, method, apiPath, body) {
7286
7423
  }
7287
7424
  return { status: upstream.status, body: parsed };
7288
7425
  }
7289
- // Which declared toolkit owns `tool` — asked of the user-plane catalog, scanning
7290
- // only the app's declared toolkits (as the server's app-plane resolver does).
7291
- async function pcResolveToolkit(ctx, entries, tool) {
7292
- for (const toolkit of pcDeclaredToolkits(entries)) {
7293
- const { status, body } = await pcForward(ctx, "GET", `/personal-connections/toolkits/${encodeURIComponent(toolkit)}/tools`);
7294
- if (status === 200 && Array.isArray(body) && body.some((t) => t.slug === tool)) {
7295
- return toolkit;
7296
- }
7297
- }
7298
- return null;
7299
- }
7300
7426
  const PC_NOT_IN_MANIFEST = (label) => ({
7301
7427
  detail: `personal connector "${label}" was not in the manifest`,
7302
7428
  });
@@ -7336,10 +7462,11 @@ async function handlePersonalConnections(ctx, req, res, path, url) {
7336
7462
  const { status, body } = await pcForward(ctx, "POST", `/personal-connections/${encodeURIComponent(toolkit)}/connect`);
7337
7463
  return sendJson(res, status, body);
7338
7464
  }
7339
- // call(tool, args) — the load-bearing gate: resolve the tool's toolkit, require
7340
- // both that toolkit declared AND this specific tool permitted, else 403 (never
7341
- // forwarded). No declaration at all nothing resolves refused, matching the
7342
- // prod chokepoint's "no manifest = refuse".
7465
+ // call(toolkit, tool, args) — the load-bearing gate. Scoped by connector, so
7466
+ // the bound is a direct match against the local manifest: the toolkit must be
7467
+ // declared AND the specific tool permitted, else 403 (never forwarded). No
7468
+ // declaration at all ⇒ refused, matching the prod chokepoint's "no manifest =
7469
+ // refuse". The user plane then verifies the (toolkit, tool) pair is real.
7343
7470
  if (rest === "/call" && req.method === "POST") {
7344
7471
  const raw = await readBody(req);
7345
7472
  let payload;
@@ -7349,16 +7476,15 @@ async function handlePersonalConnections(ctx, req, res, path, url) {
7349
7476
  catch {
7350
7477
  return sendJson(res, 400, { detail: "body must be JSON" });
7351
7478
  }
7352
- const tool = payload.tool;
7353
- if (!tool)
7354
- return sendJson(res, 400, { detail: '"tool" is required' });
7355
- if (entries.length === 0)
7356
- return sendJson(res, 403, PC_NOT_IN_MANIFEST(tool));
7357
- const toolkit = await pcResolveToolkit(ctx, entries, tool);
7358
- if (!toolkit || !pcAllows(entries, toolkit, tool)) {
7359
- return sendJson(res, 403, PC_NOT_IN_MANIFEST(tool));
7479
+ const { toolkit, tool } = payload;
7480
+ if (!toolkit || !tool) {
7481
+ return sendJson(res, 400, { detail: '"toolkit" and "tool" are required' });
7482
+ }
7483
+ if (!pcAllows(entries, toolkit, tool)) {
7484
+ return sendJson(res, 403, PC_NOT_IN_MANIFEST(`${toolkit}:${tool}`));
7360
7485
  }
7361
7486
  const { status, body } = await pcForward(ctx, "POST", "/personal-connections/call", {
7487
+ toolkit,
7362
7488
  tool,
7363
7489
  arguments: payload.arguments ?? {},
7364
7490
  });
package/dist/manifest.js CHANGED
@@ -48,7 +48,7 @@ const SAVED_QUERY_NAME = /^[a-z0-9_]{1,80}$/;
48
48
  // Mirrors the server's _PERSONAL_CONNECTOR_RE exactly. The server is
49
49
  // authoritative; this exists so `railcode deploy` can reject a bad manifest
50
50
  // locally instead of after a round trip.
51
- const PERSONAL_CONNECTOR = /^[a-z0-9][a-z0-9_]{0,63}(?::(?:\*|[A-Z0-9_]{1,120}))?$/;
51
+ const PERSONAL_CONNECTOR = /^[a-z0-9][a-z0-9_]{0,63}(?::(?:\*|[A-Za-z0-9_]{1,120}))?$/;
52
52
  // PyYAML's YAML 1.1 plain-scalar resolver: a bare (unquoted) scalar matching one
53
53
  // of these resolves to a non-string (bool/null/int/float/timestamp), and the
54
54
  // server rejects a non-string wherever the manifest wants a name. The quirks are
@@ -653,7 +653,7 @@ function validateDoc(doc, lines) {
653
653
  doc.agents = [...new Set(doc.agents)].sort();
654
654
  for (const entry of doc.personal_connectors) {
655
655
  if (!PERSONAL_CONNECTOR.test(entry)) {
656
- throw new ManifestParseError(`"${entry}" is not a valid personal_connectors entry — use "gmail", "gmail:*", or "gmail:GMAIL_SEND_EMAIL"`, lineOf(entry));
656
+ throw new ManifestParseError(`"${entry}" is not a valid personal_connectors entry — use "gmail", "gmail:*", or "gmail:send_email"`, lineOf(entry));
657
657
  }
658
658
  }
659
659
  doc.personal_connectors = [...new Set(doc.personal_connectors)].sort();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "railcode",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "Developer CLI for the multi-tenant Railcode platform: log in, scaffold, and deploy static apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,5 +27,8 @@
27
27
  "devDependencies": {
28
28
  "@types/node": "^24.0.0",
29
29
  "typescript": "^5.9.0"
30
+ },
31
+ "dependencies": {
32
+ "yaml": "^2.9.0"
30
33
  }
31
34
  }