@usefillo/cli 0.14.1 → 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
@@ -321,9 +321,9 @@ async function startLoopbackServer() {
321
321
  respond(200, "You can close this tab and return to your terminal.");
322
322
  handler?.resolve(code);
323
323
  });
324
- await new Promise((resolve4, reject) => {
324
+ await new Promise((resolve5, reject) => {
325
325
  server.once("error", reject);
326
- server.listen(0, "127.0.0.1", resolve4);
326
+ server.listen(0, "127.0.0.1", resolve5);
327
327
  });
328
328
  const port = server.address().port;
329
329
  const close = () => {
@@ -332,7 +332,7 @@ async function startLoopbackServer() {
332
332
  } catch {
333
333
  }
334
334
  };
335
- const waitForCode = (state, timeoutMs) => new Promise((resolve4, reject) => {
335
+ const waitForCode = (state, timeoutMs) => new Promise((resolve5, reject) => {
336
336
  expectedState = state;
337
337
  const timer = setTimeout(() => {
338
338
  handler = null;
@@ -343,7 +343,7 @@ async function startLoopbackServer() {
343
343
  resolve: (code) => {
344
344
  clearTimeout(timer);
345
345
  close();
346
- resolve4(code);
346
+ resolve5(code);
347
347
  },
348
348
  reject: (err) => {
349
349
  clearTimeout(timer);
@@ -583,23 +583,41 @@ Login failed: ${body.error}`);
583
583
  }
584
584
  die("\nLogin timed out \u2014 run `fillo login` again.");
585
585
  }
586
- async function fetchWorkspaceName(apiBase = API) {
586
+ async function fetchWhoami(apiBase = API) {
587
587
  const res = await api("/cli/whoami", { token: requireTokenFor(apiBase) }, apiBase);
588
588
  if (res.status === 401) die("Token invalid \u2014 run `fillo login` again.");
589
589
  if (!res.ok) die(`whoami failed (${res.status}).`);
590
590
  const me = await readJson(res);
591
- if (typeof me.workspace !== "string" || !terminalText(me.workspace)) {
592
- die("Fillo returned an invalid workspace identity.");
593
- }
594
- 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;
595
600
  }
596
601
  function printConnected(workspace) {
597
602
  console.log(` ${okMark()} Connected to ${bold(workspace)}`);
598
603
  }
599
604
  async function whoami(flags) {
600
- const workspace = await fetchWorkspaceName(API);
601
- if (jsonMode(flags)) return emitResult({ connected: true, workspace });
602
- 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
+ }
603
621
  }
604
622
  function logout() {
605
623
  const { token, tokenApi, ...rest } = readConfig();
@@ -624,6 +642,8 @@ var whoamiCommand = {
624
642
 
625
643
  // src/commands/init.ts
626
644
  import { execFile } from "child_process";
645
+ import { existsSync } from "fs";
646
+ import { basename, dirname, join as join2, resolve } from "path";
627
647
  import { promisify } from "util";
628
648
 
629
649
  // src/lib/prompt.ts
@@ -638,9 +658,9 @@ function promptInterface() {
638
658
  shared = createInterface({ input: process.stdin, output: process.stdout });
639
659
  shared.on("line", (line) => {
640
660
  if (pending) {
641
- const resolve4 = pending;
661
+ const resolve5 = pending;
642
662
  pending = null;
643
- resolve4({ line });
663
+ resolve5({ line });
644
664
  } else {
645
665
  lineQueue.push(line);
646
666
  }
@@ -653,9 +673,9 @@ function promptInterface() {
653
673
  sharedClosed = true;
654
674
  shared = null;
655
675
  if (pending) {
656
- const resolve4 = pending;
676
+ const resolve5 = pending;
657
677
  pending = null;
658
- resolve4({ eof: true });
678
+ resolve5({ eof: true });
659
679
  }
660
680
  });
661
681
  }
@@ -677,10 +697,10 @@ function ask(promptText) {
677
697
  if (!rl) return Promise.resolve({ eof: true });
678
698
  rl.setPrompt(promptText);
679
699
  rl.prompt();
680
- return new Promise((resolve4) => {
700
+ return new Promise((resolve5) => {
681
701
  pending = (answer) => {
682
702
  rl.setPrompt("");
683
- resolve4(answer);
703
+ resolve5(answer);
684
704
  };
685
705
  });
686
706
  }
@@ -706,7 +726,7 @@ var ETX = String.fromCharCode(3);
706
726
  var DEL = String.fromCharCode(127);
707
727
  var BACKSPACE = "\b";
708
728
  function readSecret(promptText) {
709
- return new Promise((resolve4, reject) => {
729
+ return new Promise((resolve5, reject) => {
710
730
  const input = process.stdin;
711
731
  if (typeof input.setRawMode !== "function") {
712
732
  reject(new Error("No interactive terminal to read a secret from."));
@@ -726,7 +746,7 @@ function readSecret(promptText) {
726
746
  if (ENTER.has(ch) || ch === EOT) {
727
747
  cleanup();
728
748
  process.stdout.write("\n");
729
- resolve4(value);
749
+ resolve5(value);
730
750
  return;
731
751
  }
732
752
  if (ch === ETX) {
@@ -781,11 +801,17 @@ async function provisionWorkspace(flags, usageHint, apiBase = API) {
781
801
  }
782
802
  }
783
803
  }
804
+ const workspaceName = flagString(flags, "workspace-name") ?? defaultWorkspaceName();
784
805
  const res = await api(
785
806
  "/workspaces/provision",
786
807
  {
787
808
  method: "POST",
788
- body: JSON.stringify({ email: email3, source: "cli", ...name ? { name } : {} })
809
+ body: JSON.stringify({
810
+ email: email3,
811
+ source: "cli",
812
+ ...name ? { name } : {},
813
+ ...workspaceName ? { workspaceName } : {}
814
+ })
789
815
  },
790
816
  apiBase
791
817
  );
@@ -800,7 +826,55 @@ async function provisionWorkspace(flags, usageHint, apiBase = API) {
800
826
  ...name ? { name } : {},
801
827
  ...claimToken ? { claimToken } : {}
802
828
  });
803
- 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;
804
878
  }
805
879
  function previewLimitLine(limits) {
806
880
  if (!limits) return null;
@@ -828,9 +902,23 @@ function claimCookieValue(res) {
828
902
  }
829
903
  async function init(flags) {
830
904
  const json = jsonMode(flags);
831
- 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;
832
918
  if (json) {
833
919
  return emitResult({
920
+ existing: false,
921
+ workspace: ws.workspace,
834
922
  pk: ws.pk,
835
923
  email: ws.email,
836
924
  name: ws.name || null,
@@ -839,7 +927,7 @@ async function init(flags) {
839
927
  }
840
928
  console.log(
841
929
  `
842
- ${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))}.`
843
931
  );
844
932
  const preview = previewLimitLine(ws.limits);
845
933
  if (preview) console.log(` ${dim(preview)}`);
@@ -850,7 +938,7 @@ async function init(flags) {
850
938
  }
851
939
  var initCommand = {
852
940
  name: "init",
853
- flags: ["email", "name"],
941
+ flags: ["email", "name", "workspace-name"],
854
942
  run: (_args, flags) => init(flags)
855
943
  };
856
944
 
@@ -858,7 +946,7 @@ var initCommand = {
858
946
  import { randomUUID } from "crypto";
859
947
  import {
860
948
  cpSync,
861
- existsSync,
949
+ existsSync as existsSync2,
862
950
  lstatSync,
863
951
  mkdirSync as mkdirSync2,
864
952
  readdirSync,
@@ -868,7 +956,7 @@ import {
868
956
  rmSync
869
957
  } from "fs";
870
958
  import { homedir as homedir2 } from "os";
871
- 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";
872
960
  import { fileURLToPath } from "url";
873
961
  var BUNDLED_SKILL_DIR = fileURLToPath(new URL("./skill/build-with-fillo", import.meta.url));
874
962
  var SKILL_AGENT_DIRECTORIES = {
@@ -892,11 +980,11 @@ function isSkillAgent(value) {
892
980
  return Object.prototype.hasOwnProperty.call(SKILL_AGENT_DIRECTORIES, value);
893
981
  }
894
982
  function findProjectRoot(start) {
895
- let current = resolve(start);
983
+ let current = resolve2(start);
896
984
  while (true) {
897
- if (existsSync(join2(current, ".git"))) return current;
898
- const parent = dirname(current);
899
- 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);
900
988
  current = parent;
901
989
  }
902
990
  }
@@ -912,7 +1000,7 @@ function parseSkillDirectories(flags) {
912
1000
  if (parts.length === 0 || parts.some((part) => part === "..")) {
913
1001
  die("--dir must name a skill directory inside the selected project or home scope.");
914
1002
  }
915
- return [join2(...parts)];
1003
+ return [join3(...parts)];
916
1004
  }
917
1005
  if (!agent2) return [SKILL_AGENT_DIRECTORIES.shared, SKILL_AGENT_DIRECTORIES.claude];
918
1006
  if (isSkillAgent(agent2)) return [SKILL_AGENT_DIRECTORIES[agent2]];
@@ -921,10 +1009,10 @@ function parseSkillDirectories(flags) {
921
1009
  );
922
1010
  }
923
1011
  function skillFiles(root, relative2 = "") {
924
- const directory = join2(root, relative2);
1012
+ const directory = join3(root, relative2);
925
1013
  const files = [];
926
1014
  for (const entry of readdirSync(directory, { withFileTypes: true })) {
927
- const next = join2(relative2, entry.name);
1015
+ const next = join3(relative2, entry.name);
928
1016
  if (entry.isDirectory()) files.push(...skillFiles(root, next));
929
1017
  else if (entry.isFile()) files.push(next);
930
1018
  }
@@ -936,7 +1024,7 @@ function sameSkillBundle(source, target) {
936
1024
  const targetFiles = skillFiles(target);
937
1025
  if (sourceFiles.length !== targetFiles.length) return false;
938
1026
  return sourceFiles.every(
939
- (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)))
940
1028
  );
941
1029
  } catch {
942
1030
  return false;
@@ -954,7 +1042,7 @@ function pathEntryExists(path) {
954
1042
  var MANAGED_SKILL_MARKER = ".fillo-managed.json";
955
1043
  function isCliManagedFilloSkill(target) {
956
1044
  try {
957
- const marker = JSON.parse(readFileSync2(join2(target, MANAGED_SKILL_MARKER), "utf8"));
1045
+ const marker = JSON.parse(readFileSync2(join3(target, MANAGED_SKILL_MARKER), "utf8"));
958
1046
  return marker.managedBy === "@usefillo/cli" && marker.skill === "build-with-fillo" && marker.format === 1 && marker.allowAutomaticUpdates === true;
959
1047
  } catch {
960
1048
  return false;
@@ -964,7 +1052,7 @@ function assertSafeSkillDestination(scopeRoot, parts) {
964
1052
  const realScopeRoot = realpathSync(scopeRoot);
965
1053
  let current = scopeRoot;
966
1054
  for (const part of parts) {
967
- current = join2(current, part);
1055
+ current = join3(current, part);
968
1056
  if (!pathEntryExists(current)) return;
969
1057
  if (lstatSync(current).isSymbolicLink()) {
970
1058
  throw new Error(`Refusing to install through symlinked path ${current}.`);
@@ -1006,12 +1094,12 @@ function installSkill(flags, opts = {}) {
1006
1094
  const skillDirectories = parseSkillDirectories(flags);
1007
1095
  const global = flags.global === true;
1008
1096
  const scopeRoot = global ? homedir2() : findProjectRoot(process.cwd());
1009
- if (!existsSync(BUNDLED_SKILL_DIR)) {
1097
+ if (!existsSync2(BUNDLED_SKILL_DIR)) {
1010
1098
  die("This CLI package is missing its bundled Fillo skill. Reinstall @usefillo/cli@latest.");
1011
1099
  }
1012
1100
  const destinations = Array.from(new Set(skillDirectories)).map((skillDirectory) => {
1013
- const skillsRoot = resolve(scopeRoot, skillDirectory);
1014
- const target = join2(skillsRoot, "build-with-fillo");
1101
+ const skillsRoot = resolve2(scopeRoot, skillDirectory);
1102
+ const target = join3(skillsRoot, "build-with-fillo");
1015
1103
  const relativeSkillsRoot = relative(scopeRoot, skillsRoot);
1016
1104
  if (relativeSkillsRoot === ".." || relativeSkillsRoot.startsWith(`..${sep}`) || isAbsolute(relativeSkillsRoot)) {
1017
1105
  die("The skill destination must stay inside the selected project or home scope.");
@@ -1037,7 +1125,7 @@ function installSkill(flags, opts = {}) {
1037
1125
  if (destination.current) continue;
1038
1126
  mkdirSync2(destination.skillsRoot, { recursive: true });
1039
1127
  assertSafeSkillDestination(scopeRoot, destination.destinationParts.slice(0, 2));
1040
- const staging = join2(destination.skillsRoot, `.build-with-fillo.install-${randomUUID()}`);
1128
+ const staging = join3(destination.skillsRoot, `.build-with-fillo.install-${randomUUID()}`);
1041
1129
  try {
1042
1130
  cpSync(BUNDLED_SKILL_DIR, staging, { recursive: true, errorOnExist: true });
1043
1131
  assertSafeSkillDestination(scopeRoot, destination.destinationParts);
@@ -1056,10 +1144,15 @@ function installSkill(flags, opts = {}) {
1056
1144
  console.log(`
1057
1145
  ${okMark()} ${action}.`);
1058
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
+ }
1059
1152
  console.log(`
1060
1153
  Ask your agent to use ${bold("build-with-fillo")}.`);
1061
1154
  console.log(
1062
- ` ${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")}.`)}
1063
1156
  `
1064
1157
  );
1065
1158
  return targets;
@@ -1085,7 +1178,8 @@ function skillHelp() {
1085
1178
  ${dimRaw("--global install for the current user")}
1086
1179
  ${dimRaw("--force replace a different existing copy")}
1087
1180
 
1088
- ${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.")}
1089
1183
  `);
1090
1184
  }
1091
1185
  var skillCommand = {
@@ -1104,30 +1198,40 @@ async function bootstrapStandalone(flags, apiBase) {
1104
1198
  );
1105
1199
  }
1106
1200
  const json = jsonMode(flags);
1107
- const ws = await provisionWorkspace(flags, BOOTSTRAP_USAGE, apiBase);
1201
+ const identity = await resolveWorkspaceIdentity(flags, BOOTSTRAP_USAGE, apiBase);
1202
+ if (!json) {
1203
+ if (identity.existing) printExistingWorkspaceGuard(identity.workspace);
1204
+ else printNewWorkspace(identity);
1205
+ }
1108
1206
  const targets = installSkill(flags, { quiet: json });
1109
1207
  if (json) {
1110
- return emitResult({
1111
- pk: ws.pk,
1112
- email: ws.email,
1113
- name: ws.name || null,
1114
- limits: ws.limits ?? null,
1115
- skill: { installed: true, targets }
1116
- });
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
+ );
1117
1219
  }
1118
1220
  console.log(
1119
1221
  `
1120
- ${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:`
1121
1223
  );
1224
+ console.log(` ${dim("fillo push form.json --handle my-form")}
1225
+ `);
1226
+ }
1227
+ function printNewWorkspace(ws) {
1228
+ console.log(`
1229
+ ${okMark()} New workspace ${bold(terminalText(ws.workspace))} provisioned.`);
1122
1230
  console.log(
1123
1231
  ` ${dim(`A workspace link was sent to ${terminalText(ws.email)} \u2014 claim it anytime with \`fillo claim\`.`)}`
1124
1232
  );
1125
1233
  const preview = previewLimitLine(ws.limits);
1126
1234
  if (preview) console.log(` ${dim(preview)}`);
1127
- console.log(`
1128
- ${bold("Next:")} tell your agent what form to build, or push one directly:`);
1129
- console.log(` ${dim("fillo push form.json --handle my-form")}
1130
- `);
1131
1235
  }
1132
1236
  var AGENT_ACTIONS = ["claim_required", "storage_required", "publish_required"];
1133
1237
  var AGENT_EVENT_STATUSES = [
@@ -1269,9 +1373,11 @@ function agentHelp() {
1269
1373
  agent bootstrap Set up a workspace and install the Build with Fillo skill
1270
1374
  ${dimRaw("--email you@company.com provision a fresh workspace (a TTY confirms your git identity)")}
1271
1375
  ${dimRaw('--name "Your Name" optional display name')}
1376
+ ${dimRaw("--workspace-name <name> name the new workspace (default: this repo/directory)")}
1272
1377
  ${dimRaw("--api <url> target a different Fillo deployment (default: FILLO_API or https://fillo.so)")}
1273
1378
  ${dimRaw("--run <id> --token <token> instead: attach to an open browser modal")}
1274
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.")}
1275
1381
  agent connect Connect a coding-agent run to the open browser modal
1276
1382
  ${dimRaw("--account attach the workspace from `fillo login`")}
1277
1383
  agent event Send a short progress update
@@ -1293,6 +1399,7 @@ var agentCommand = {
1293
1399
  flags: [
1294
1400
  "email",
1295
1401
  "name",
1402
+ "workspace-name",
1296
1403
  "run",
1297
1404
  "token",
1298
1405
  "api",
@@ -1734,7 +1841,7 @@ var deleteCommand = {
1734
1841
 
1735
1842
  // src/commands/forms.ts
1736
1843
  import { readFileSync as readFileSync3 } from "fs";
1737
- import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
1844
+ import { isAbsolute as isAbsolute2, resolve as resolve3 } from "path";
1738
1845
  import { pathToFileURL } from "url";
1739
1846
 
1740
1847
  // ../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/core/core.js
@@ -4166,7 +4273,7 @@ async function loadResponseData(file) {
4166
4273
  die(`Couldn't read response data from stdin: ${error.message}`);
4167
4274
  }
4168
4275
  } else {
4169
- const abs = isAbsolute2(file) ? file : resolve2(process.cwd(), file);
4276
+ const abs = isAbsolute2(file) ? file : resolve3(process.cwd(), file);
4170
4277
  if (!abs.endsWith(".json")) {
4171
4278
  die("Test response data must be a .json file (or - for JSON on stdin).");
4172
4279
  }
@@ -4245,7 +4352,7 @@ async function loadSchema(file, allowCode) {
4245
4352
  die(`Couldn't read the schema from stdin: ${error.message}`);
4246
4353
  }
4247
4354
  }
4248
- const abs = isAbsolute2(file) ? file : resolve2(process.cwd(), file);
4355
+ const abs = isAbsolute2(file) ? file : resolve3(process.cwd(), file);
4249
4356
  if (!abs.endsWith(".json") && !allowCode) {
4250
4357
  die(
4251
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.`
@@ -4286,7 +4393,7 @@ function toPushItems(value, handle) {
4286
4393
  return { handle, schema: item };
4287
4394
  });
4288
4395
  }
4289
- function printPushed(formId, url, updated, draft = false) {
4396
+ function printPushed(formId, url, updated, draft = false, storage2 = {}) {
4290
4397
  const label = draft ? "Created draft" : updated ? "Updated" : "Created";
4291
4398
  console.log(`
4292
4399
  ${okMark()} ${label} ${bold(formId)}`);
@@ -4295,6 +4402,8 @@ function printPushed(formId, url, updated, draft = false) {
4295
4402
  ` ${bold("Publish:")} fillo publish ${terminalText(formId)} ${dim("(or publish it from the Fillo dashboard)")}`
4296
4403
  );
4297
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)}`);
4298
4407
  console.log(` Embed: <FilloForm formId="${terminalText(formId)}" />
4299
4408
  `);
4300
4409
  console.log(` Verify: [data-fillo-form-id="${terminalText(formId)}"] on the actual app route
@@ -4437,7 +4546,10 @@ async function push(file, flags) {
4437
4546
  results.push(body);
4438
4547
  emitProgress({ status: "pushed", formId: body.formId, draft: true });
4439
4548
  } else {
4440
- 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
+ });
4441
4553
  }
4442
4554
  }
4443
4555
  if (results) emitResult({ forms: results });
@@ -4470,7 +4582,10 @@ async function push(file, flags) {
4470
4582
  results.push(body);
4471
4583
  emitProgress({ status: "pushed", formId: body.formId, published: true });
4472
4584
  } else {
4473
- 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
+ });
4474
4589
  }
4475
4590
  }
4476
4591
  if (results) emitResult({ forms: results });
@@ -4786,7 +4901,7 @@ var membersCommand = {
4786
4901
  // src/commands/responses.ts
4787
4902
  import { once } from "events";
4788
4903
  import { createWriteStream } from "fs";
4789
- import { isAbsolute as isAbsolute3, resolve as resolve3 } from "path";
4904
+ import { isAbsolute as isAbsolute3, resolve as resolve4 } from "path";
4790
4905
  var dateOnly4 = (iso) => iso.slice(0, 10);
4791
4906
  var dateTime = (iso) => iso.slice(0, 16).replace("T", " ");
4792
4907
  async function dieOnNotFound(res, handle, verb) {
@@ -4868,7 +4983,7 @@ async function exportCsv(handle, flags) {
4868
4983
  const body = await readJson(res);
4869
4984
  die(body.error ?? `responses export failed (${res.status}).`);
4870
4985
  }
4871
- 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;
4872
4987
  const file = path ? createWriteStream(path) : void 0;
4873
4988
  const target = file ?? process.stdout;
4874
4989
  let bytes = 0;
@@ -5330,6 +5445,13 @@ async function status3(flags) {
5330
5445
  const target = body.defaultStorageProvider;
5331
5446
  const targetLabel = target && target in PROVIDER_LABEL ? PROVIDER_LABEL[target] : target ?? "no durable storage yet";
5332
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
+ }
5333
5455
  console.log("");
5334
5456
  }
5335
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.1",
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": [