@fui-org/fui-cli 2.1.1 → 2.1.3

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/dist/fui.js CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- import{enableCompileCache as a}from"node:module";a?.();var{run:e}=await import("./fui-kchph3mb.js");await e();
2
+ import{enableCompileCache as a}from"node:module";a?.();var{run:e}=await import("./fui-hk5frcn2.js");await e();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fui-org/fui-cli",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
4
4
  "description": "Token-lean CLI for the FUI low-code platform (Node.js)",
5
5
  "keywords": [
6
6
  "ai",
@@ -67,7 +67,7 @@ description: Use the `fui` CLI for all FUI (low-code platform) work — projects
67
67
  - Unchanged files are skipped via the publish ledger.
68
68
  5. `fui file status` lists what is still unpublished.
69
69
 
70
- `fui remote module --part ui|html|script|css|load|components|imports` and `fui remote project --part config|modules|…` show the server copy without touching the workspace. `fui remote diff <path...>` compares local files with the server.
70
+ `fui remote module --part ui|html|script|css|load|components|imports` and `fui remote project --part config|modules|…` show the server copy without touching the workspace. `fui remote diff [path...]` compares local files with the server (no path: the whole target module); `--stat` adds the +/- line counts.
71
71
 
72
72
  Ask the user before `module delete`, `component delete`, `import delete` and `user remove`.
73
73
 
@@ -1,7 +1,7 @@
1
1
  import { atom, read, update } from "claude-code";
2
2
  import type { EngineInterface as Engine, Register } from "claude-code";
3
3
 
4
- import type { FuiTarget } from "../types";
4
+ import type { FuiDiff, FuiTarget } from "../types";
5
5
 
6
6
  type SessionTarget = { projectId?: string; moduleId?: string; moduleDir?: string; workspaceRoot?: string };
7
7
 
@@ -12,11 +12,49 @@ const FUI_CALL = /(?:^|[;&|(]|\bthen|\bdo)\s*(?:[A-Za-z_]\w*=\S*\s+)*(?:bunx\s+)
12
12
 
13
13
  // Mirrors sessionFile() in src/core/session/state.ts.
14
14
  async function sessionFile($: Engine, command: string) {
15
- const key = command.match(/FUI_SESSION=([A-Za-z0-9_-]+)/)?.[1] ?? (await $.env.get("FUI_SESSION")) ?? "default";
15
+ const key = sessionKey(command) ?? (await $.env.get("FUI_SESSION")) ?? "default";
16
16
  const dir = (await $.env.get("FUI_SESSION_DIR")) ?? `${await $.env.get("HOME")}/.fui/session`;
17
17
  return `${dir}/cli-${key}.json`;
18
18
  }
19
19
 
20
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
21
+
22
+ function sessionKey(command: string) {
23
+ return command.match(/FUI_SESSION=([A-Za-z0-9_-]+)/)?.[1];
24
+ }
25
+
26
+ async function runFui($: Engine, args: string[], session: string | undefined) {
27
+ const env = session ? { FUI_SESSION: session } : undefined;
28
+ try {
29
+ return await $.process.run(["fui", ...args], { env, timeoutMs: 30_000 });
30
+ } catch {
31
+ // fui is often on a PATH only the login shell sets up (bun, nvm).
32
+ const shell = (await $.env.get("SHELL")) ?? "/bin/zsh";
33
+ return await $.process.run([shell, "-lc", `fui ${args.join(" ")}`], { env, timeoutMs: 30_000 });
34
+ }
35
+ }
36
+
37
+ // Runs in the background: the numbers land on the line, never in the model's context.
38
+ async function remoteDiff($: Engine, current: FuiTarget): Promise<FuiDiff | undefined> {
39
+ const args = ["remote", "diff", "--stat", "--json"];
40
+ if (current.project && current.module) args.push("-p", current.project, "-m", current.module);
41
+ const { exitCode, stdout } = await runFui($, args, session).catch(() => ({
42
+ exitCode: 1,
43
+ stdout: "",
44
+ }));
45
+ if (exitCode !== 0) return undefined;
46
+ try {
47
+ const rows = JSON.parse(stdout.slice(stdout.indexOf("["))) as { added?: number; removed?: number }[];
48
+ return {
49
+ files: rows.length,
50
+ added: rows.reduce((sum, row) => sum + (row.added ?? 0), 0),
51
+ removed: rows.reduce((sum, row) => sum + (row.removed ?? 0), 0),
52
+ };
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ }
57
+
20
58
  async function readJson($: Engine, path: string): Promise<unknown> {
21
59
  try {
22
60
  return JSON.parse(await $.fs.read(path));
@@ -64,22 +102,96 @@ async function projectUrl($: Engine, session: SessionTarget) {
64
102
  return httpsUrl(firstDomain(info?.pDomain));
65
103
  }
66
104
 
67
- async function sync($: Engine, command: string, lastCommand: string) {
68
- const state = (await readJson($, await sessionFile($, command))) as { target?: SessionTarget } | undefined;
69
- const session = state?.target;
105
+ async function describe($: Engine, session: SessionTarget | undefined, lastCommand?: string): Promise<FuiTarget> {
70
106
  const found: FuiTarget = { lastCommand };
71
107
  if (session?.projectId) {
72
108
  found.project = session.projectId;
73
109
  found.module = session.moduleId;
110
+ found.moduleDir = session.moduleDir;
74
111
  found.projectUrl = await projectUrl($, session);
75
112
  found.moduleUrl = session.moduleId ? await moduleUrl($, session) : undefined;
76
113
  }
77
- await update($, target, () => found);
114
+ return found;
115
+ }
116
+
117
+ async function readSessionTarget($: Engine, command: string) {
118
+ const state = (await readJson($, await sessionFile($, command))) as { target?: SessionTarget } | undefined;
119
+ return state?.target;
120
+ }
121
+
122
+ // A folder holding _db or _projectInfo.json is a fui project; <project>/modules/<module> inside it is a module.
123
+ async function folderTarget($: Engine, cwd: string): Promise<SessionTarget | undefined> {
124
+ const parts = cwd.replace(/\/+$/, "").split("/");
125
+ for (let end = parts.length; end > 1; end--) {
126
+ const projectDir = parts.slice(0, end).join("/");
127
+ if (!(await $.fs.exists(`${projectDir}/_db`)) && !(await $.fs.exists(`${projectDir}/_projectInfo.json`))) continue;
128
+ const info = (await readJson($, `${projectDir}/_projectInfo.json`)) as { ProjectID?: unknown } | undefined;
129
+ const projectId = typeof info?.ProjectID === "string" ? info.ProjectID : parts[end - 1];
130
+ const workspaceRoot = parts.slice(0, end - 1).join("/");
131
+ const moduleId = parts[end] === "modules" ? parts[end + 1] : undefined;
132
+ if (moduleId) return { projectId, moduleId, moduleDir: `${projectDir}/modules/${moduleId}`, workspaceRoot };
133
+ const active = await readSessionTarget($, "");
134
+ return active?.projectId === projectId ? active : { projectId, workspaceRoot };
135
+ }
136
+ return undefined;
137
+ }
138
+
139
+ let session: string | undefined;
140
+ let isPinned = false;
141
+ let isStale = false;
142
+ let running: Promise<void> | undefined;
143
+
144
+ async function showDiff($: Engine, diff: FuiTarget["diff"]) {
145
+ const current: FuiTarget | null = await read($, target);
146
+ if (current?.moduleDir) await update($, target, () => ({ ...current, diff }));
147
+ }
148
+
149
+ function refreshDiff($: Engine) {
150
+ isStale = false;
151
+ running ??= (async () => {
152
+ do {
153
+ isStale = false;
154
+ await showDiff($, "pending");
155
+ const current: FuiTarget | null = await read($, target);
156
+ if (current?.moduleDir) await showDiff($, await remoteDiff($, current));
157
+ } while (isStale);
158
+ })().finally(() => {
159
+ running = undefined;
160
+ });
78
161
  }
79
162
 
80
163
  export const register: Register = (on) => {
164
+ on("session.start", async ($, e, next) => {
165
+ const started = await next(e);
166
+ const folder = await folderTarget($, e.cwd);
167
+ isPinned = folder !== undefined;
168
+ if (folder) {
169
+ await update($, target, () => null);
170
+ const found = await describe($, folder);
171
+ await update($, target, () => found);
172
+ refreshDiff($);
173
+ }
174
+ return started;
175
+ });
176
+
81
177
  on("prompt.submit", async ($, e, next) => {
82
- await update($, target, () => null);
178
+ if (!isPinned) await update($, target, () => null);
179
+ return next(e);
180
+ });
181
+
182
+ on("tool.call", async ($, e, next) => {
183
+ const result = await next(e);
184
+ if (!EDIT_TOOLS.has(e.tool)) return result;
185
+ const current: FuiTarget | null = await read($, target);
186
+ const path =
187
+ (e as { file_path?: string; notebook_path?: string }).file_path ??
188
+ (e as { notebook_path?: string }).notebook_path;
189
+ if (current?.moduleDir && path?.startsWith(`${current.moduleDir}/`)) isStale = true;
190
+ return result;
191
+ });
192
+
193
+ on("turn.complete", async ($, e, next) => {
194
+ if (isStale) refreshDiff($);
83
195
  return next(e);
84
196
  });
85
197
 
@@ -88,7 +200,14 @@ export const register: Register = (on) => {
88
200
  const call = e.command.match(FUI_CALL);
89
201
  if (!call) return result;
90
202
 
91
- await sync($, e.command, [call[1], call[2]].filter(Boolean).join(" "));
203
+ session = sessionKey(e.command);
204
+ const found = await describe(
205
+ $,
206
+ await readSessionTarget($, e.command),
207
+ [call[1], call[2]].filter(Boolean).join(" "),
208
+ );
209
+ await update($, target, () => found);
210
+ if (!(call[1] === "remote" && call[2] === "diff")) refreshDiff($);
92
211
  return result;
93
212
  });
94
213
 
@@ -120,7 +239,23 @@ export const register: Register = (on) => {
120
239
  ) : (
121
240
  <Text dimColor>no active module</Text>
122
241
  )}
123
- {current.lastCommand && <Text dimColor> · fui {current.lastCommand}</Text>}
242
+ {current.diff === "pending" && <Text dimColor> · diff…</Text>}
243
+ {current.diff && current.diff !== "pending" && (
244
+ <Text>
245
+ <Text dimColor> · </Text>
246
+ {current.diff.files === 0 ? (
247
+ <Text dimColor>in sync</Text>
248
+ ) : (
249
+ <Text>
250
+ <Text>{`${current.diff.files} file${current.diff.files === 1 ? "" : "s"} `}</Text>
251
+ <Text color="green">{`+${current.diff.added}`}</Text>
252
+ <Text> </Text>
253
+ <Text color="red">{`−${current.diff.removed}`}</Text>
254
+ </Text>
255
+ )}
256
+ </Text>
257
+ )}
258
+ {current.lastCommand && <Text dimColor>{` · fui ${current.lastCommand}`}</Text>}
124
259
  </Box>
125
260
  );
126
261
  });
@@ -1,9 +1,13 @@
1
+ export type FuiDiff = { files: number; added: number; removed: number };
2
+
1
3
  export type FuiTarget = {
2
4
  project?: string;
3
5
  module?: string;
6
+ moduleDir?: string;
4
7
  projectUrl?: string;
5
8
  moduleUrl?: string;
6
9
  lastCommand?: string;
10
+ diff?: FuiDiff | "pending";
7
11
  };
8
12
 
9
13
  declare module "claude-code" {