@usefillo/cli 0.14.0 → 0.15.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
@@ -29,9 +29,10 @@ Commands: `init`, `login`, `logout`, `whoami`, `push <file|->`, `list`,
29
29
  `agent bootstrap`, `agent connect`, `agent event`, and `skill install`. Run
30
30
  `npx @usefillo/cli --help` for flags. The canonical skill is
31
31
  one portable Agent Skills bundle. The default command installs it in the shared
32
- `.agents/skills` path and Claude Code's `.claude/skills` path. Hosts with another
33
- location can use `skill install --dir <agent-skill-directory>`, so the same
34
- bundle works without provider-specific forks. See
32
+ `.agents/skills` path and Claude Code's `.claude/skills` path — both get the
33
+ same files, for cross-agent compatibility rather than duplication. Hosts with
34
+ another location can use `skill install --dir <agent-skill-directory>`, so the
35
+ same bundle works without provider-specific forks. See
35
36
  [fillo.so/agents](https://fillo.so/agents) for setup. The API
36
37
  commands target
37
38
  `https://fillo.so` by default (set `FILLO_API` to override).
package/dist/index.js CHANGED
@@ -91,7 +91,9 @@ function die(msg) {
91
91
  }
92
92
 
93
93
  // src/lib/api.ts
94
- var API = (process.env.FILLO_API ?? "https://fillo.so").replace(/\/$/, "");
94
+ var DEFAULT_API = "https://fillo.so";
95
+ var apiOverride = process.env.FILLO_API?.trim();
96
+ var API = (apiOverride || DEFAULT_API).replace(/\/$/, "");
95
97
  var REQUEST_TIMEOUT_MS = 3e4;
96
98
  var SYNC_TOKEN_ENV = "FILLO_SYNC_TOKEN";
97
99
  function requireTokenFor(apiBase) {
@@ -113,24 +115,51 @@ function requireTokenFor(apiBase) {
113
115
  function requireToken() {
114
116
  return requireTokenFor(API);
115
117
  }
118
+ function networkFailure(apiBase, error) {
119
+ const timedOut = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
120
+ const reason = causeCode(error) ?? (timedOut ? `no response within ${REQUEST_TIMEOUT_MS / 1e3}s` : void 0);
121
+ const hint = apiBase === DEFAULT_API ? "Check your network connection and try again." : `The CLI is pointed there by FILLO_API or --api \u2014 unset the override to use ${DEFAULT_API}, or start that deployment.`;
122
+ return new Error(
123
+ `Couldn't reach ${terminalText(apiBase)}${reason ? ` (${reason})` : ""}. ${hint}`,
124
+ {
125
+ cause: error
126
+ }
127
+ );
128
+ }
129
+ function causeCode(error, depth = 0) {
130
+ if (!error || typeof error !== "object" || depth > 8) return void 0;
131
+ const { code, errors, cause } = error;
132
+ if (typeof code === "string" && code) return code;
133
+ if (Array.isArray(errors)) {
134
+ for (const nested of errors) {
135
+ const found = causeCode(nested, depth + 1);
136
+ if (found) return found;
137
+ }
138
+ }
139
+ return causeCode(cause, depth + 1);
140
+ }
116
141
  async function api(path, init2 = {}, apiBase = API) {
117
142
  const { token, ...rest } = init2;
118
- return fetch(`${apiBase}/api/v1${path}`, {
119
- ...rest,
120
- signal: rest.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
121
- headers: {
122
- "Content-Type": "application/json",
123
- ...token ? { Authorization: `Bearer ${token}` } : {},
124
- ...rest.headers
125
- }
126
- });
143
+ try {
144
+ return await fetch(`${apiBase}/api/v1${path}`, {
145
+ ...rest,
146
+ signal: rest.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
147
+ headers: {
148
+ "Content-Type": "application/json",
149
+ ...token ? { Authorization: `Bearer ${token}` } : {},
150
+ ...rest.headers
151
+ }
152
+ });
153
+ } catch (error) {
154
+ throw networkFailure(apiBase, error);
155
+ }
127
156
  }
128
- async function readJson(res) {
157
+ async function readJson(res, apiBase = API) {
129
158
  const text = await res.text();
130
159
  try {
131
160
  return text ? JSON.parse(text) : {};
132
161
  } catch {
133
- die(`Unexpected non-JSON response from ${API} (${res.status}).`);
162
+ die(`Unexpected non-JSON response from ${apiBase} (${res.status}).`);
134
163
  }
135
164
  }
136
165
 
@@ -292,9 +321,9 @@ async function startLoopbackServer() {
292
321
  respond(200, "You can close this tab and return to your terminal.");
293
322
  handler?.resolve(code);
294
323
  });
295
- await new Promise((resolve4, reject) => {
324
+ await new Promise((resolve5, reject) => {
296
325
  server.once("error", reject);
297
- server.listen(0, "127.0.0.1", resolve4);
326
+ server.listen(0, "127.0.0.1", resolve5);
298
327
  });
299
328
  const port = server.address().port;
300
329
  const close = () => {
@@ -303,7 +332,7 @@ async function startLoopbackServer() {
303
332
  } catch {
304
333
  }
305
334
  };
306
- const waitForCode = (state, timeoutMs) => new Promise((resolve4, reject) => {
335
+ const waitForCode = (state, timeoutMs) => new Promise((resolve5, reject) => {
307
336
  expectedState = state;
308
337
  const timer = setTimeout(() => {
309
338
  handler = null;
@@ -314,7 +343,7 @@ async function startLoopbackServer() {
314
343
  resolve: (code) => {
315
344
  clearTimeout(timer);
316
345
  close();
317
- resolve4(code);
346
+ resolve5(code);
318
347
  },
319
348
  reject: (err) => {
320
349
  clearTimeout(timer);
@@ -554,23 +583,41 @@ Login failed: ${body.error}`);
554
583
  }
555
584
  die("\nLogin timed out \u2014 run `fillo login` again.");
556
585
  }
557
- async function fetchWorkspaceName(apiBase = API) {
586
+ async function fetchWhoami(apiBase = API) {
558
587
  const res = await api("/cli/whoami", { token: requireTokenFor(apiBase) }, apiBase);
559
588
  if (res.status === 401) die("Token invalid \u2014 run `fillo login` again.");
560
589
  if (!res.ok) die(`whoami failed (${res.status}).`);
561
590
  const me = await readJson(res);
562
- if (typeof me.workspace !== "string" || !terminalText(me.workspace)) {
563
- die("Fillo returned an invalid workspace identity.");
564
- }
565
- return terminalText(me.workspace);
591
+ const workspace = typeof me.workspace === "string" ? terminalText(me.workspace) : "";
592
+ if (!workspace) die("Fillo returned an invalid workspace identity.");
593
+ return {
594
+ workspace,
595
+ canPublishFileFields: typeof me.canPublishFileFields === "boolean" ? me.canPublishFileFields : void 0
596
+ };
597
+ }
598
+ async function fetchWorkspaceName(apiBase = API) {
599
+ return (await fetchWhoami(apiBase)).workspace;
566
600
  }
567
601
  function printConnected(workspace) {
568
602
  console.log(` ${okMark()} Connected to ${bold(workspace)}`);
569
603
  }
570
604
  async function whoami(flags) {
571
- const workspace = await fetchWorkspaceName(API);
572
- if (jsonMode(flags)) return emitResult({ connected: true, workspace });
573
- printConnected(workspace);
605
+ const me = await fetchWhoami(API);
606
+ if (jsonMode(flags)) {
607
+ return emitResult({
608
+ connected: true,
609
+ workspace: me.workspace,
610
+ ...me.canPublishFileFields === void 0 ? {} : { canPublishFileFields: me.canPublishFileFields }
611
+ });
612
+ }
613
+ printConnected(me.workspace);
614
+ if (me.canPublishFileFields === true) {
615
+ console.log(` ${dim("File uploads: storage ready \u2014 a file field can publish now.")}`);
616
+ } else if (me.canPublishFileFields === false) {
617
+ console.log(
618
+ ` ${dim("File uploads: no destination yet \u2014 connect one (fillo storage connect) before publishing a file field.")}`
619
+ );
620
+ }
574
621
  }
575
622
  function logout() {
576
623
  const { token, tokenApi, ...rest } = readConfig();
@@ -595,6 +642,8 @@ var whoamiCommand = {
595
642
 
596
643
  // src/commands/init.ts
597
644
  import { execFile } from "child_process";
645
+ import { existsSync } from "fs";
646
+ import { basename, dirname, join as join2, resolve } from "path";
598
647
  import { promisify } from "util";
599
648
 
600
649
  // src/lib/prompt.ts
@@ -609,9 +658,9 @@ function promptInterface() {
609
658
  shared = createInterface({ input: process.stdin, output: process.stdout });
610
659
  shared.on("line", (line) => {
611
660
  if (pending) {
612
- const resolve4 = pending;
661
+ const resolve5 = pending;
613
662
  pending = null;
614
- resolve4({ line });
663
+ resolve5({ line });
615
664
  } else {
616
665
  lineQueue.push(line);
617
666
  }
@@ -624,9 +673,9 @@ function promptInterface() {
624
673
  sharedClosed = true;
625
674
  shared = null;
626
675
  if (pending) {
627
- const resolve4 = pending;
676
+ const resolve5 = pending;
628
677
  pending = null;
629
- resolve4({ eof: true });
678
+ resolve5({ eof: true });
630
679
  }
631
680
  });
632
681
  }
@@ -648,10 +697,10 @@ function ask(promptText) {
648
697
  if (!rl) return Promise.resolve({ eof: true });
649
698
  rl.setPrompt(promptText);
650
699
  rl.prompt();
651
- return new Promise((resolve4) => {
700
+ return new Promise((resolve5) => {
652
701
  pending = (answer) => {
653
702
  rl.setPrompt("");
654
- resolve4(answer);
703
+ resolve5(answer);
655
704
  };
656
705
  });
657
706
  }
@@ -677,7 +726,7 @@ var ETX = String.fromCharCode(3);
677
726
  var DEL = String.fromCharCode(127);
678
727
  var BACKSPACE = "\b";
679
728
  function readSecret(promptText) {
680
- return new Promise((resolve4, reject) => {
729
+ return new Promise((resolve5, reject) => {
681
730
  const input = process.stdin;
682
731
  if (typeof input.setRawMode !== "function") {
683
732
  reject(new Error("No interactive terminal to read a secret from."));
@@ -697,7 +746,7 @@ function readSecret(promptText) {
697
746
  if (ENTER.has(ch) || ch === EOT) {
698
747
  cleanup();
699
748
  process.stdout.write("\n");
700
- resolve4(value);
749
+ resolve5(value);
701
750
  return;
702
751
  }
703
752
  if (ch === ETX) {
@@ -722,7 +771,7 @@ function readSecret(promptText) {
722
771
  // src/commands/init.ts
723
772
  var EMAIL_REQUIRED = 'Usage: fillo init --email you@company.com [--name "Your Name"] (or run `fillo login` for an existing account).';
724
773
  var execFileAsync = promisify(execFile);
725
- async function provisionWorkspace(flags, usageHint) {
774
+ async function provisionWorkspace(flags, usageHint, apiBase = API) {
726
775
  const json = jsonMode(flags);
727
776
  let email3 = flagString(flags, "email")?.trim() ?? "";
728
777
  let name = flagString(flags, "name")?.trim() ?? "";
@@ -752,11 +801,21 @@ async function provisionWorkspace(flags, usageHint) {
752
801
  }
753
802
  }
754
803
  }
755
- const res = await api("/workspaces/provision", {
756
- method: "POST",
757
- body: JSON.stringify({ email: email3, source: "cli", ...name ? { name } : {} })
758
- });
759
- const body = await readJson(res);
804
+ const workspaceName = flagString(flags, "workspace-name") ?? defaultWorkspaceName();
805
+ const res = await api(
806
+ "/workspaces/provision",
807
+ {
808
+ method: "POST",
809
+ body: JSON.stringify({
810
+ email: email3,
811
+ source: "cli",
812
+ ...name ? { name } : {},
813
+ ...workspaceName ? { workspaceName } : {}
814
+ })
815
+ },
816
+ apiBase
817
+ );
818
+ const body = await readJson(res, apiBase);
760
819
  if (!res.ok || !body.key) die(body.error ?? `Provisioning failed (${res.status}).`);
761
820
  const claimToken = claimCookieValue(res);
762
821
  const { claimUrl: _legacyWorkspaceLink, ...current } = readConfig();
@@ -767,7 +826,55 @@ async function provisionWorkspace(flags, usageHint) {
767
826
  ...name ? { name } : {},
768
827
  ...claimToken ? { claimToken } : {}
769
828
  });
770
- return { pk: body.key, email: email3, name, limits: body.limits };
829
+ const workspace = typeof body.workspaceName === "string" && terminalText(body.workspaceName) || workspaceName || fallbackWorkspaceName(name, email3);
830
+ return { pk: body.key, email: email3, name, workspace, limits: body.limits };
831
+ }
832
+ async function resolveWorkspaceIdentity(flags, usageHint, apiBase = API) {
833
+ const existing = await existingAccountWorkspace(apiBase);
834
+ if (existing) return { existing: true, workspace: existing };
835
+ const ws = await provisionWorkspace(flags, usageHint, apiBase);
836
+ return { existing: false, ...ws };
837
+ }
838
+ async function existingAccountWorkspace(apiBase) {
839
+ const cfg = readConfig();
840
+ if (!cfg.token || cfg.tokenApi?.replace(/\/$/, "") !== apiBase.replace(/\/$/, "")) {
841
+ return void 0;
842
+ }
843
+ const res = await api("/cli/whoami", { token: cfg.token }, apiBase).catch(() => null);
844
+ if (!res?.ok) return void 0;
845
+ const me = await res.json().catch(() => ({}));
846
+ return typeof me.workspace === "string" && terminalText(me.workspace) ? terminalText(me.workspace) : void 0;
847
+ }
848
+ function printExistingWorkspaceGuard(workspace) {
849
+ console.log(
850
+ `
851
+ ${danger(`Using your stored Fillo login \u2014 workspace "${terminalText(workspace)}".`)}`
852
+ );
853
+ console.log(
854
+ ` ${dim("This is a real, connected workspace, not a fresh preview: forms you push go live in it.")}`
855
+ );
856
+ console.log(
857
+ ` ${dim("Want an isolated preview instead? Run `fillo logout`, then re-run this command.")}`
858
+ );
859
+ }
860
+ function fallbackWorkspaceName(name, email3) {
861
+ const owner = name.split(/\s+/)[0]?.trim() || email3.split("@")[0]?.trim() || "New";
862
+ return `${owner}'s workspace`;
863
+ }
864
+ function defaultWorkspaceName() {
865
+ let current = resolve(process.cwd());
866
+ let root = current;
867
+ while (true) {
868
+ if (existsSync(join2(current, ".git"))) {
869
+ root = current;
870
+ break;
871
+ }
872
+ const parent = dirname(current);
873
+ if (parent === current) break;
874
+ current = parent;
875
+ }
876
+ const cleaned = terminalText(basename(root)).slice(0, 80);
877
+ return cleaned || void 0;
771
878
  }
772
879
  function previewLimitLine(limits) {
773
880
  if (!limits) return null;
@@ -795,13 +902,32 @@ function claimCookieValue(res) {
795
902
  }
796
903
  async function init(flags) {
797
904
  const json = jsonMode(flags);
798
- const ws = await provisionWorkspace(flags, EMAIL_REQUIRED);
905
+ const identity = await resolveWorkspaceIdentity(flags, EMAIL_REQUIRED);
906
+ if (identity.existing) {
907
+ if (json) return emitResult({ existing: true, workspace: identity.workspace });
908
+ printExistingWorkspaceGuard(identity.workspace);
909
+ console.log(`
910
+ ${bold("Next:")} fillo push form.json --handle my-form`);
911
+ console.log(
912
+ ` ${dim(`Pushes publish into ${terminalText(identity.workspace)} \u2014 the workspace above.`)}
913
+ `
914
+ );
915
+ return;
916
+ }
917
+ const ws = identity;
799
918
  if (json) {
800
- return emitResult({ pk: ws.pk, email: ws.email, name: ws.name || null, limits: ws.limits ?? null });
919
+ return emitResult({
920
+ existing: false,
921
+ workspace: ws.workspace,
922
+ pk: ws.pk,
923
+ email: ws.email,
924
+ name: ws.name || null,
925
+ limits: ws.limits ?? null
926
+ });
801
927
  }
802
928
  console.log(
803
929
  `
804
- ${okMark()} Workspace ready. A workspace link was sent to ${bold(terminalText(ws.email))}.`
930
+ ${okMark()} New workspace ${bold(terminalText(ws.workspace))} ready. A link was sent to ${bold(terminalText(ws.email))}.`
805
931
  );
806
932
  const preview = previewLimitLine(ws.limits);
807
933
  if (preview) console.log(` ${dim(preview)}`);
@@ -812,7 +938,7 @@ async function init(flags) {
812
938
  }
813
939
  var initCommand = {
814
940
  name: "init",
815
- flags: ["email", "name"],
941
+ flags: ["email", "name", "workspace-name"],
816
942
  run: (_args, flags) => init(flags)
817
943
  };
818
944
 
@@ -820,7 +946,7 @@ var initCommand = {
820
946
  import { randomUUID } from "crypto";
821
947
  import {
822
948
  cpSync,
823
- existsSync,
949
+ existsSync as existsSync2,
824
950
  lstatSync,
825
951
  mkdirSync as mkdirSync2,
826
952
  readdirSync,
@@ -830,7 +956,7 @@ import {
830
956
  rmSync
831
957
  } from "fs";
832
958
  import { homedir as homedir2 } from "os";
833
- import { dirname, isAbsolute, join as join2, relative, resolve, sep } from "path";
959
+ import { dirname as dirname2, isAbsolute, join as join3, relative, resolve as resolve2, sep } from "path";
834
960
  import { fileURLToPath } from "url";
835
961
  var BUNDLED_SKILL_DIR = fileURLToPath(new URL("./skill/build-with-fillo", import.meta.url));
836
962
  var SKILL_AGENT_DIRECTORIES = {
@@ -854,11 +980,11 @@ function isSkillAgent(value) {
854
980
  return Object.prototype.hasOwnProperty.call(SKILL_AGENT_DIRECTORIES, value);
855
981
  }
856
982
  function findProjectRoot(start) {
857
- let current = resolve(start);
983
+ let current = resolve2(start);
858
984
  while (true) {
859
- if (existsSync(join2(current, ".git"))) return current;
860
- const parent = dirname(current);
861
- if (parent === current) return resolve(start);
985
+ if (existsSync2(join3(current, ".git"))) return current;
986
+ const parent = dirname2(current);
987
+ if (parent === current) return resolve2(start);
862
988
  current = parent;
863
989
  }
864
990
  }
@@ -874,7 +1000,7 @@ function parseSkillDirectories(flags) {
874
1000
  if (parts.length === 0 || parts.some((part) => part === "..")) {
875
1001
  die("--dir must name a skill directory inside the selected project or home scope.");
876
1002
  }
877
- return [join2(...parts)];
1003
+ return [join3(...parts)];
878
1004
  }
879
1005
  if (!agent2) return [SKILL_AGENT_DIRECTORIES.shared, SKILL_AGENT_DIRECTORIES.claude];
880
1006
  if (isSkillAgent(agent2)) return [SKILL_AGENT_DIRECTORIES[agent2]];
@@ -883,10 +1009,10 @@ function parseSkillDirectories(flags) {
883
1009
  );
884
1010
  }
885
1011
  function skillFiles(root, relative2 = "") {
886
- const directory = join2(root, relative2);
1012
+ const directory = join3(root, relative2);
887
1013
  const files = [];
888
1014
  for (const entry of readdirSync(directory, { withFileTypes: true })) {
889
- const next = join2(relative2, entry.name);
1015
+ const next = join3(relative2, entry.name);
890
1016
  if (entry.isDirectory()) files.push(...skillFiles(root, next));
891
1017
  else if (entry.isFile()) files.push(next);
892
1018
  }
@@ -898,7 +1024,7 @@ function sameSkillBundle(source, target) {
898
1024
  const targetFiles = skillFiles(target);
899
1025
  if (sourceFiles.length !== targetFiles.length) return false;
900
1026
  return sourceFiles.every(
901
- (file, index) => file === targetFiles[index] && readFileSync2(join2(source, file)).equals(readFileSync2(join2(target, file)))
1027
+ (file, index) => file === targetFiles[index] && readFileSync2(join3(source, file)).equals(readFileSync2(join3(target, file)))
902
1028
  );
903
1029
  } catch {
904
1030
  return false;
@@ -916,7 +1042,7 @@ function pathEntryExists(path) {
916
1042
  var MANAGED_SKILL_MARKER = ".fillo-managed.json";
917
1043
  function isCliManagedFilloSkill(target) {
918
1044
  try {
919
- const marker = JSON.parse(readFileSync2(join2(target, MANAGED_SKILL_MARKER), "utf8"));
1045
+ const marker = JSON.parse(readFileSync2(join3(target, MANAGED_SKILL_MARKER), "utf8"));
920
1046
  return marker.managedBy === "@usefillo/cli" && marker.skill === "build-with-fillo" && marker.format === 1 && marker.allowAutomaticUpdates === true;
921
1047
  } catch {
922
1048
  return false;
@@ -926,7 +1052,7 @@ function assertSafeSkillDestination(scopeRoot, parts) {
926
1052
  const realScopeRoot = realpathSync(scopeRoot);
927
1053
  let current = scopeRoot;
928
1054
  for (const part of parts) {
929
- current = join2(current, part);
1055
+ current = join3(current, part);
930
1056
  if (!pathEntryExists(current)) return;
931
1057
  if (lstatSync(current).isSymbolicLink()) {
932
1058
  throw new Error(`Refusing to install through symlinked path ${current}.`);
@@ -968,12 +1094,12 @@ function installSkill(flags, opts = {}) {
968
1094
  const skillDirectories = parseSkillDirectories(flags);
969
1095
  const global = flags.global === true;
970
1096
  const scopeRoot = global ? homedir2() : findProjectRoot(process.cwd());
971
- if (!existsSync(BUNDLED_SKILL_DIR)) {
1097
+ if (!existsSync2(BUNDLED_SKILL_DIR)) {
972
1098
  die("This CLI package is missing its bundled Fillo skill. Reinstall @usefillo/cli@latest.");
973
1099
  }
974
1100
  const destinations = Array.from(new Set(skillDirectories)).map((skillDirectory) => {
975
- const skillsRoot = resolve(scopeRoot, skillDirectory);
976
- const target = join2(skillsRoot, "build-with-fillo");
1101
+ const skillsRoot = resolve2(scopeRoot, skillDirectory);
1102
+ const target = join3(skillsRoot, "build-with-fillo");
977
1103
  const relativeSkillsRoot = relative(scopeRoot, skillsRoot);
978
1104
  if (relativeSkillsRoot === ".." || relativeSkillsRoot.startsWith(`..${sep}`) || isAbsolute(relativeSkillsRoot)) {
979
1105
  die("The skill destination must stay inside the selected project or home scope.");
@@ -999,7 +1125,7 @@ function installSkill(flags, opts = {}) {
999
1125
  if (destination.current) continue;
1000
1126
  mkdirSync2(destination.skillsRoot, { recursive: true });
1001
1127
  assertSafeSkillDestination(scopeRoot, destination.destinationParts.slice(0, 2));
1002
- const staging = join2(destination.skillsRoot, `.build-with-fillo.install-${randomUUID()}`);
1128
+ const staging = join3(destination.skillsRoot, `.build-with-fillo.install-${randomUUID()}`);
1003
1129
  try {
1004
1130
  cpSync(BUNDLED_SKILL_DIR, staging, { recursive: true, errorOnExist: true });
1005
1131
  assertSafeSkillDestination(scopeRoot, destination.destinationParts);
@@ -1018,10 +1144,15 @@ function installSkill(flags, opts = {}) {
1018
1144
  console.log(`
1019
1145
  ${okMark()} ${action}.`);
1020
1146
  for (const target of targets) console.log(` ${dim(target)}`);
1147
+ if (targets.length > 1) {
1148
+ console.log(
1149
+ ` ${dim("Same bundle in both \u2014 cross-agent compatibility, not duplication.")}`
1150
+ );
1151
+ }
1021
1152
  console.log(`
1022
1153
  Ask your agent to use ${bold("build-with-fillo")}.`);
1023
1154
  console.log(
1024
- ` ${dim(`If it does not discover skills, point it to ${join2(targets[0], "SKILL.md")}.`)}
1155
+ ` ${dim(`If it does not discover skills, point it to ${join3(targets[0], "SKILL.md")}.`)}
1025
1156
  `
1026
1157
  );
1027
1158
  return targets;
@@ -1047,7 +1178,8 @@ function skillHelp() {
1047
1178
  ${dimRaw("--global install for the current user")}
1048
1179
  ${dimRaw("--force replace a different existing copy")}
1049
1180
 
1050
- ${dimRaw("Default: open Agent Skills path (.agents/skills) plus Claude Code (.claude/skills).")}
1181
+ ${dimRaw("Default: open Agent Skills path (.agents/skills) plus Claude Code (.claude/skills) \u2014")}
1182
+ ${dimRaw("the same bundle in both, so either agent convention discovers it.")}
1051
1183
  `);
1052
1184
  }
1053
1185
  var skillCommand = {
@@ -1059,34 +1191,48 @@ var skillCommand = {
1059
1191
 
1060
1192
  // src/commands/agent.ts
1061
1193
  var BOOTSTRAP_USAGE = 'To set up a workspace I need an email for your recovery link. Re-run: npx @usefillo/cli@latest agent bootstrap --email you@company.com [--name "Your Name"]. (Already have an account? `fillo login`.)';
1062
- async function bootstrapStandalone(flags) {
1194
+ async function bootstrapStandalone(flags, apiBase) {
1063
1195
  if (flags.account === true) {
1064
- die("--account is for the browser handoff: `fillo agent bootstrap --run <id> --token <token> --account`.");
1196
+ die(
1197
+ "--account is for the browser handoff: `fillo agent bootstrap --run <id> --token <token> --account`."
1198
+ );
1065
1199
  }
1066
1200
  const json = jsonMode(flags);
1067
- const ws = await provisionWorkspace(flags, BOOTSTRAP_USAGE);
1201
+ const identity = await resolveWorkspaceIdentity(flags, BOOTSTRAP_USAGE, apiBase);
1202
+ if (!json) {
1203
+ if (identity.existing) printExistingWorkspaceGuard(identity.workspace);
1204
+ else printNewWorkspace(identity);
1205
+ }
1068
1206
  const targets = installSkill(flags, { quiet: json });
1069
1207
  if (json) {
1070
- return emitResult({
1071
- pk: ws.pk,
1072
- email: ws.email,
1073
- name: ws.name || null,
1074
- limits: ws.limits ?? null,
1075
- skill: { installed: true, targets }
1076
- });
1208
+ return emitResult(
1209
+ identity.existing ? { existing: true, workspace: identity.workspace, skill: { installed: true, targets } } : {
1210
+ existing: false,
1211
+ workspace: identity.workspace,
1212
+ pk: identity.pk,
1213
+ email: identity.email,
1214
+ name: identity.name || null,
1215
+ limits: identity.limits ?? null,
1216
+ skill: { installed: true, targets }
1217
+ }
1218
+ );
1077
1219
  }
1078
1220
  console.log(
1079
1221
  `
1080
- ${bold("Ready.")} Workspace provisioned and the Build with Fillo skill installed.`
1222
+ ${bold("Next:")} tell your agent what form to build${identity.existing ? ` in ${bold(terminalText(identity.workspace))}` : ""}, or push one directly:`
1081
1223
  );
1082
- console.log(` ${dim(`A workspace link was sent to ${terminalText(ws.email)} \u2014 claim it anytime with \`fillo claim\`.`)}`);
1083
- const preview = previewLimitLine(ws.limits);
1084
- if (preview) console.log(` ${dim(preview)}`);
1085
- console.log(`
1086
- ${bold("Next:")} tell your agent what form to build, or push one directly:`);
1087
1224
  console.log(` ${dim("fillo push form.json --handle my-form")}
1088
1225
  `);
1089
1226
  }
1227
+ function printNewWorkspace(ws) {
1228
+ console.log(`
1229
+ ${okMark()} New workspace ${bold(terminalText(ws.workspace))} provisioned.`);
1230
+ console.log(
1231
+ ` ${dim(`A workspace link was sent to ${terminalText(ws.email)} \u2014 claim it anytime with \`fillo claim\`.`)}`
1232
+ );
1233
+ const preview = previewLimitLine(ws.limits);
1234
+ if (preview) console.log(` ${dim(preview)}`);
1235
+ }
1090
1236
  var AGENT_ACTIONS = ["claim_required", "storage_required", "publish_required"];
1091
1237
  var AGENT_EVENT_STATUSES = [
1092
1238
  "created",
@@ -1112,7 +1258,7 @@ async function agent(subcommand, flags) {
1112
1258
  die("--account does not take a value.");
1113
1259
  }
1114
1260
  if (subcommand === "bootstrap") {
1115
- if (!run || !token) return bootstrapStandalone(flags);
1261
+ if (!run || !token) return bootstrapStandalone(flags, apiBase);
1116
1262
  installSkill(flags);
1117
1263
  if (flags.account === true) {
1118
1264
  await login({ ...flags, json: false }, { continueToAgentRun: true });
@@ -1176,29 +1322,39 @@ async function connectAgentRun(apiBase, run, token, attachAccount) {
1176
1322
  );
1177
1323
  }
1178
1324
  async function postAgentEvent(apiBase, run, token, body) {
1179
- const res = await fetch(`${apiBase}/api/v1/agent-runs/${encodeURIComponent(run)}/events`, {
1180
- method: "POST",
1181
- headers: {
1182
- Authorization: `Bearer ${token}`,
1183
- "Content-Type": "application/json"
1184
- },
1185
- body: JSON.stringify(body),
1186
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
1187
- });
1325
+ let res;
1326
+ try {
1327
+ res = await fetch(`${apiBase}/api/v1/agent-runs/${encodeURIComponent(run)}/events`, {
1328
+ method: "POST",
1329
+ headers: {
1330
+ Authorization: `Bearer ${token}`,
1331
+ "Content-Type": "application/json"
1332
+ },
1333
+ body: JSON.stringify(body),
1334
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
1335
+ });
1336
+ } catch (error) {
1337
+ throw networkFailure(apiBase, error);
1338
+ }
1188
1339
  const payload = await res.json().catch(() => ({}));
1189
1340
  if (!res.ok) die(payload.error ?? `agent progress failed (${res.status}).`);
1190
1341
  }
1191
1342
  async function attachAgentAccount(apiBase, run, progressToken) {
1192
1343
  const accountToken = requireTokenFor(apiBase);
1193
- const res = await fetch(`${apiBase}/api/v1/agent-runs/${encodeURIComponent(run)}/account`, {
1194
- method: "POST",
1195
- redirect: "error",
1196
- headers: {
1197
- Authorization: `Bearer ${accountToken}`,
1198
- "X-Fillo-Agent-Run-Token": progressToken
1199
- },
1200
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
1201
- });
1344
+ let res;
1345
+ try {
1346
+ res = await fetch(`${apiBase}/api/v1/agent-runs/${encodeURIComponent(run)}/account`, {
1347
+ method: "POST",
1348
+ redirect: "error",
1349
+ headers: {
1350
+ Authorization: `Bearer ${accountToken}`,
1351
+ "X-Fillo-Agent-Run-Token": progressToken
1352
+ },
1353
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
1354
+ });
1355
+ } catch (error) {
1356
+ throw networkFailure(apiBase, error);
1357
+ }
1202
1358
  const payload = await res.json().catch(() => ({}));
1203
1359
  if (!res.ok) {
1204
1360
  const hint = res.status === 401 ? " Run `fillo login` and try again." : "";
@@ -1217,8 +1373,11 @@ function agentHelp() {
1217
1373
  agent bootstrap Set up a workspace and install the Build with Fillo skill
1218
1374
  ${dimRaw("--email you@company.com provision a fresh workspace (a TTY confirms your git identity)")}
1219
1375
  ${dimRaw('--name "Your Name" optional display name')}
1376
+ ${dimRaw("--workspace-name <name> name the new workspace (default: this repo/directory)")}
1377
+ ${dimRaw("--api <url> target a different Fillo deployment (default: FILLO_API or https://fillo.so)")}
1220
1378
  ${dimRaw("--run <id> --token <token> instead: attach to an open browser modal")}
1221
1379
  ${dimRaw("--account with --run/--token: approve an existing workspace in the browser")}
1380
+ ${dimRaw("A stored `fillo login` is reused as-is (and named) \u2014 run `fillo logout` first for an isolated preview.")}
1222
1381
  agent connect Connect a coding-agent run to the open browser modal
1223
1382
  ${dimRaw("--account attach the workspace from `fillo login`")}
1224
1383
  agent event Send a short progress update
@@ -1240,6 +1399,7 @@ var agentCommand = {
1240
1399
  flags: [
1241
1400
  "email",
1242
1401
  "name",
1402
+ "workspace-name",
1243
1403
  "run",
1244
1404
  "token",
1245
1405
  "api",
@@ -1681,7 +1841,7 @@ var deleteCommand = {
1681
1841
 
1682
1842
  // src/commands/forms.ts
1683
1843
  import { readFileSync as readFileSync3 } from "fs";
1684
- import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
1844
+ import { isAbsolute as isAbsolute2, resolve as resolve3 } from "path";
1685
1845
  import { pathToFileURL } from "url";
1686
1846
 
1687
1847
  // ../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/core/core.js
@@ -4113,7 +4273,7 @@ async function loadResponseData(file) {
4113
4273
  die(`Couldn't read response data from stdin: ${error.message}`);
4114
4274
  }
4115
4275
  } else {
4116
- const abs = isAbsolute2(file) ? file : resolve2(process.cwd(), file);
4276
+ const abs = isAbsolute2(file) ? file : resolve3(process.cwd(), file);
4117
4277
  if (!abs.endsWith(".json")) {
4118
4278
  die("Test response data must be a .json file (or - for JSON on stdin).");
4119
4279
  }
@@ -4192,7 +4352,7 @@ async function loadSchema(file, allowCode) {
4192
4352
  die(`Couldn't read the schema from stdin: ${error.message}`);
4193
4353
  }
4194
4354
  }
4195
- const abs = isAbsolute2(file) ? file : resolve2(process.cwd(), file);
4355
+ const abs = isAbsolute2(file) ? file : resolve3(process.cwd(), file);
4196
4356
  if (!abs.endsWith(".json") && !allowCode) {
4197
4357
  die(
4198
4358
  `${file} is a code module \u2014 pushing it executes the file. Re-run with --allow-code if you trust it, or push a .json schema instead.`
@@ -4233,7 +4393,7 @@ function toPushItems(value, handle) {
4233
4393
  return { handle, schema: item };
4234
4394
  });
4235
4395
  }
4236
- function printPushed(formId, url, updated, draft = false) {
4396
+ function printPushed(formId, url, updated, draft = false, storage2 = {}) {
4237
4397
  const label = draft ? "Created draft" : updated ? "Updated" : "Created";
4238
4398
  console.log(`
4239
4399
  ${okMark()} ${label} ${bold(formId)}`);
@@ -4242,6 +4402,8 @@ function printPushed(formId, url, updated, draft = false) {
4242
4402
  ` ${bold("Publish:")} fillo publish ${terminalText(formId)} ${dim("(or publish it from the Fillo dashboard)")}`
4243
4403
  );
4244
4404
  } else if (url) console.log(` Live at ${terminalText(url)}`);
4405
+ if (storage2.warning) console.log(` ${dim(`Before publishing: ${storage2.warning}`)}`);
4406
+ if (storage2.warningUrl) console.log(` Storage settings: ${terminalText(storage2.warningUrl)}`);
4245
4407
  console.log(` Embed: <FilloForm formId="${terminalText(formId)}" />
4246
4408
  `);
4247
4409
  console.log(` Verify: [data-fillo-form-id="${terminalText(formId)}"] on the actual app route
@@ -4384,7 +4546,10 @@ async function push(file, flags) {
4384
4546
  results.push(body);
4385
4547
  emitProgress({ status: "pushed", formId: body.formId, draft: true });
4386
4548
  } else {
4387
- printPushed(body.formId, body.url, !!body.updated, true);
4549
+ printPushed(body.formId, body.url, !!body.updated, true, {
4550
+ warning: body.warning,
4551
+ warningUrl: body.warningUrl
4552
+ });
4388
4553
  }
4389
4554
  }
4390
4555
  if (results) emitResult({ forms: results });
@@ -4417,7 +4582,10 @@ async function push(file, flags) {
4417
4582
  results.push(body);
4418
4583
  emitProgress({ status: "pushed", formId: body.formId, published: true });
4419
4584
  } else {
4420
- printPushed(body.formId, body.url, !!body.updated);
4585
+ printPushed(body.formId, body.url, !!body.updated, false, {
4586
+ warning: body.warning,
4587
+ warningUrl: body.warningUrl
4588
+ });
4421
4589
  }
4422
4590
  }
4423
4591
  if (results) emitResult({ forms: results });
@@ -4733,7 +4901,7 @@ var membersCommand = {
4733
4901
  // src/commands/responses.ts
4734
4902
  import { once } from "events";
4735
4903
  import { createWriteStream } from "fs";
4736
- import { isAbsolute as isAbsolute3, resolve as resolve3 } from "path";
4904
+ import { isAbsolute as isAbsolute3, resolve as resolve4 } from "path";
4737
4905
  var dateOnly4 = (iso) => iso.slice(0, 10);
4738
4906
  var dateTime = (iso) => iso.slice(0, 16).replace("T", " ");
4739
4907
  async function dieOnNotFound(res, handle, verb) {
@@ -4815,7 +4983,7 @@ async function exportCsv(handle, flags) {
4815
4983
  const body = await readJson(res);
4816
4984
  die(body.error ?? `responses export failed (${res.status}).`);
4817
4985
  }
4818
- const path = out ? isAbsolute3(out) ? out : resolve3(process.cwd(), out) : void 0;
4986
+ const path = out ? isAbsolute3(out) ? out : resolve4(process.cwd(), out) : void 0;
4819
4987
  const file = path ? createWriteStream(path) : void 0;
4820
4988
  const target = file ?? process.stdout;
4821
4989
  let bytes = 0;
@@ -5277,6 +5445,13 @@ async function status3(flags) {
5277
5445
  const target = body.defaultStorageProvider;
5278
5446
  const targetLabel = target && target in PROVIDER_LABEL ? PROVIDER_LABEL[target] : target ?? "no durable storage yet";
5279
5447
  console.log(` ${dim("Uploads for storage=null forms resolve to:")} ${targetLabel}`);
5448
+ if (body.canPublishFileFields === true) {
5449
+ console.log(` ${dim("Can publish file fields:")} yes`);
5450
+ } else if (body.canPublishFileFields === false) {
5451
+ console.log(
5452
+ ` ${dim("Can publish file fields:")} not yet \u2014 connect or choose a destination first`
5453
+ );
5454
+ }
5280
5455
  console.log("");
5281
5456
  }
5282
5457
  var S3_FIELDS = [
@@ -17,7 +17,15 @@ Never require a provider-specific agent command.
17
17
  1. Inspect the host repository. Identify its framework, package manager, target
18
18
  route, UI conventions, existing Fillo packages, and any supplied form id,
19
19
  key, setup command, or run token.
20
- 2. Establish the form's source of truth:
20
+ 2. If the form may need uploads, check storage readiness before choosing the
21
+ schema, not after a blocked publish. With a CLI login, `fillo whoami` or
22
+ `fillo storage status` (both `--json`) report `canPublishFileFields`:
23
+ `true` means a `file_upload` field can publish now; `false` means a
24
+ destination still needs connecting (a provider can show connected while
25
+ no default destination is resolved). Use that signal to decide on file
26
+ fields — do not defensively drop one just because storage looks unset; ask
27
+ the user to finish connecting it instead.
28
+ 3. Establish the form's source of truth:
21
29
  - Published form id or slug: render it directly. No client key is required.
22
30
  - React-owned schema: use `<Fillo.Form>` or `defineForm()` with
23
31
  `@usefillo/react`.
@@ -32,13 +40,13 @@ Never require a provider-specific agent command.
32
40
  client. A plain `FormSchema` plus a client is not a code-defined form and
33
41
  cannot resolve a target. Use explicit `renderOnly` only for a deliberately
34
42
  non-submitting UI preview.
35
- 3. Ask only for missing product decisions that change the result: purpose,
43
+ 4. Ask only for missing product decisions that change the result: purpose,
36
44
  placement, required questions or files, conditional behavior, and what
37
45
  happens after submit. Infer routine implementation details from the repo.
38
- 4. If the prompt supplies a handoff command, workspace key, form id, or run
46
+ 5. If the prompt supplies a handoff command, workspace key, form id, or run
39
47
  token, follow that handoff exactly. Do not create a second workspace or save
40
48
  a run token.
41
- 5. Implement the smallest complete form, verify it in the host app, and report
49
+ 6. Implement the smallest complete form, verify it in the host app, and report
42
50
  any remaining dashboard action honestly.
43
51
 
44
52
  ## Load only the needed reference
@@ -42,9 +42,13 @@ Never inspect `~/.fillo/config.json`, expose the account token, or call
42
42
 
43
43
  ## Claim the workspace from the terminal
44
44
 
45
- A capped preview workspace becomes a full account by being claimed. Claim it
46
- without leaving the terminal — this replaces sending the user to a browser to
47
- save the workspace:
45
+ A capped preview workspace becomes a full account by being claimed. The
46
+ preview cap is 10 responses total across every form in the workspace (not per
47
+ form) plus a time window; past either limit, a new submission is rejected
48
+ outright — HTTP 403 with `{error, closed: true}` — never queued or silently
49
+ dropped, and the workspace's email is notified once. Claim it without leaving
50
+ the terminal — this replaces sending the user to a browser to save the
51
+ workspace:
48
52
 
49
53
  ```bash
50
54
  npx @usefillo/cli@latest claim
@@ -209,7 +213,11 @@ most one line at the end of the chat summary.
209
213
  - Add `--json` to a command to get one final JSON object on stdout (progress
210
214
  lines go to stderr as JSON). Parse that object instead of scraping human
211
215
  output when a step feeds later automation — for example, capture `formId` from
212
- `push --json` or the key id from `keys list --json`.
216
+ `push --json` or the key id from `keys list --json`. `push --json` returns the
217
+ full lifecycle for each form in one round-trip — `formId`, `slug`, `status`,
218
+ `staged`, `accepting`, `uploadsAvailable`, `canPublishFileFields`, and any
219
+ storage `warning`/`warningUrl` — so a push needs no follow-up `status` call to
220
+ learn whether it staged, published, or is accepting responses.
213
221
  - The CLI enters agent mode automatically when stdout is not a TTY, or when
214
222
  `FILLO_AGENT=1` is set: no color, no spinners, and it never opens a browser —
215
223
  it prints the URL for the user to open instead. Print that URL and stop; the
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Uploads and customer storage
4
4
 
5
+ Check storage readiness before choosing the schema, not after a blocked
6
+ publish. With a CLI login, `fillo storage status` (or `whoami`) reports
7
+ whether uploads are durable right now. A provider can show connected while no
8
+ default destination is resolved yet — that combination cannot durably store
9
+ uploads, so treat it as "not ready," not as a reason to drop a needed file
10
+ field; ask the user to finish connecting a destination instead.
11
+
5
12
  Model a file requirement with a real `file_upload` field and validate its
6
13
  limits against the current schema reference:
7
14
 
@@ -46,8 +53,21 @@ var. Never put the secret access key in shell history where you can avoid it;
46
53
  prefer the env var or the hidden prompt. Google Drive and Box connect over
47
54
  OAuth: `storage connect drive` (or `box`) prints an approval URL for the user to
48
55
  open — print it and let them approve, do not loop. `storage` with no argument
49
- reports each provider's connection and the transit window. This clears the
50
- `storage_required` publish blocker for the S3/R2 case without a dashboard trip.
56
+ reports each provider's connection, the transit window, the destination a
57
+ `storage = null` form resolves to, and a single `canPublishFileFields` boolean.
58
+ This clears the `storage_required` publish blocker for the S3/R2 case without a
59
+ dashboard trip.
60
+
61
+ `canPublishFileFields` is the truthful pre-authoring answer to "can I ship a
62
+ file field right now?" — `true` when a default upload destination resolves
63
+ (an implicit pin, one connected durable provider, or the transit allowance).
64
+ `fillo whoami --json` carries the same boolean, so you can check it at bootstrap
65
+ before designing the form. It is workspace-scoped and deliberately distinct from
66
+ a form's `uploadsAvailable`: several providers connected with none chosen as the
67
+ default reads `canPublishFileFields: false` even though storage IS connected,
68
+ because a `storage = null` file field would still be blocked until one is
69
+ picked. Check this before authoring a `file_upload` field, not after a blocked
70
+ publish.
51
71
 
52
72
  Test with one safe file. Confirm both the response reference and object in the
53
73
  connected storage. Treat filenames and file contents as untrusted.
@@ -10,8 +10,14 @@ eligibility, follow-up, or the work performed after submission.
10
10
  has a real type. Do not model everything as text.
11
11
  - Use single-select for one stored choice and multi-select for several. Keep
12
12
  option ids stable even when labels change.
13
- - Use `file_upload` only when the file is necessary and supported storage can
14
- be connected before publish.
13
+ - Use `file_upload` when the file is genuinely necessary. Before authoring
14
+ the field, read `canPublishFileFields` from `fillo whoami --json` or
15
+ `fillo storage status --json` (see [operations.md](operations.md)): `true`
16
+ means it can publish now. If `false`, connect a destination
17
+ (`fillo storage connect …`) or tell the user — do not defensively drop the
18
+ field; storage is a pre-flight check, not a publish-time surprise. A
19
+ specific form's `uploadsAvailable` is a different, per-form flag and is
20
+ trivially `true` before any file field exists, so it cannot answer this.
15
21
  - Put known product context in prefill or a hidden field instead of asking the
16
22
  respondent to re-enter it. Treat URL prefill as untrusted input.
17
23
  - Split long or conceptually separate flows into pages. Keep short embedded
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefillo/cli",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Create and publish Fillo forms, and install the Fillo Agent Skill.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "scripts": {
32
32
  "build": "tsup && node scripts/copy-skill.mjs",
33
- "test": "node scripts/validate-skill-bundle.mjs && node scripts/test-skill-bundle.mjs && node scripts/test-skill-install.mjs && node scripts/test-agent-account.mjs && node scripts/test-stage-push.mjs && node scripts/test-local-validation.mjs && node scripts/test-loopback.mjs && node scripts/test-init.mjs && node scripts/test-init-tty.mjs && node scripts/test-keys-claim.mjs && node scripts/test-responses.mjs && node scripts/test-workspace-commands.mjs",
33
+ "test": "node scripts/validate-skill-bundle.mjs && node scripts/test-skill-bundle.mjs && node scripts/test-skill-install.mjs && node scripts/test-agent-account.mjs && node scripts/test-stage-push.mjs && node scripts/test-local-validation.mjs && node scripts/test-loopback.mjs && node scripts/test-init.mjs && node scripts/test-api-target.mjs && node scripts/test-init-tty.mjs && node scripts/test-keys-claim.mjs && node scripts/test-responses.mjs && node scripts/test-workspace-commands.mjs",
34
34
  "typecheck": "tsc --noEmit"
35
35
  }
36
36
  }