@fui-org/fui-cli 2.1.0 → 2.1.2

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-cx180e19.js");await e();
2
+ import{enableCompileCache as a}from"node:module";a?.();var{run:e}=await import("./fui-x86j3m2z.js");await e();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fui-org/fui-cli",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
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,43 +1,171 @@
1
1
  import { atom, read, update } from "claude-code";
2
- import type { Engine, Register } from "claude-code";
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
+
6
+ type SessionTarget = { projectId?: string; moduleId?: string; moduleDir?: string; workspaceRoot?: string };
5
7
 
6
8
  const target = atom({ plugin: "fui-target", key: "target" } as const, null);
7
9
 
8
- const FUI_CALL = /(?:^|[\s;&|(])fui\s+([a-z-]+)(?:\s+([a-z-]+))?/;
10
+ // fui must be the program a shell segment runs, so `grep fui src` or `cd fui-cli` never count.
11
+ const FUI_CALL = /(?:^|[;&|(]|\bthen|\bdo)\s*(?:[A-Za-z_]\w*=\S*\s+)*(?:bunx\s+)?fui\s+([a-z-]+)(?:\s+([a-z-]+))?/m;
9
12
 
10
13
  // Mirrors sessionFile() in src/core/session/state.ts.
11
14
  async function sessionFile($: Engine, command: string) {
12
- const key = command.match(/FUI_SESSION=([A-Za-z0-9_-]+)/)?.[1] ?? (await $.env.get("FUI_SESSION")) ?? "default";
13
- const dir =
14
- (await $.env.get("FUI_SESSION_DIR")) ??
15
- (await $.env.get("FUI_MCP_SESSION_DIR")) ??
16
- `${await $.env.get("HOME")}/.fui-mcp/session`;
15
+ const key = sessionKey(command) ?? (await $.env.get("FUI_SESSION")) ?? "default";
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
- async function sync($: Engine, command: string, lastCommand: string) {
21
- const text = await $.fs.read(await sessionFile($, command)).catch(() => "");
22
- let state: { target?: { projectId?: string; moduleId?: string } } = {};
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;
23
28
  try {
24
- state = JSON.parse(text || "{}");
25
- } catch {}
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
+ }
26
36
 
27
- await update($, target, () => ({
28
- project: state.target?.projectId,
29
- module: state.target?.moduleId,
30
- lastCommand,
37
+ // Runs in the background: the numbers land on the line, never in the model's context.
38
+ async function remoteDiff($: Engine, session: string | undefined): Promise<FuiDiff | undefined> {
39
+ const { exitCode, stdout } = await runFui($, ["remote", "diff", "--stat", "--json"], session).catch(() => ({
40
+ exitCode: 1,
41
+ stdout: "",
31
42
  }));
43
+ if (exitCode !== 0) return undefined;
44
+ try {
45
+ const rows = JSON.parse(stdout.slice(stdout.indexOf("["))) as { added?: number; removed?: number }[];
46
+ return {
47
+ files: rows.length,
48
+ added: rows.reduce((sum, row) => sum + (row.added ?? 0), 0),
49
+ removed: rows.reduce((sum, row) => sum + (row.removed ?? 0), 0),
50
+ };
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ async function readJson($: Engine, path: string): Promise<unknown> {
57
+ try {
58
+ return JSON.parse(await $.fs.read(path));
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
63
+
64
+ function httpsUrl(raw: unknown): string | undefined {
65
+ if (typeof raw !== "string" || !raw.trim()) return undefined;
66
+ const text = raw.trim();
67
+ try {
68
+ return new URL(text.startsWith("//") ? `https:${text}` : /^https?:\/\//i.test(text) ? text : `https://${text}`)
69
+ .href;
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ }
74
+
75
+ // pDomain is a host, or on multi-domain projects a JSON array of hosts kept as a string (see parseDomainField).
76
+ function firstDomain(raw: unknown): string | undefined {
77
+ if (typeof raw !== "string") return undefined;
78
+ const text = raw.trim();
79
+ if (!text.startsWith("[")) return text || undefined;
80
+ try {
81
+ const hosts: unknown = JSON.parse(text);
82
+ return Array.isArray(hosts) && typeof hosts[0] === "string" ? hosts[0].trim() : undefined;
83
+ } catch {
84
+ return text;
85
+ }
86
+ }
87
+
88
+ async function moduleUrl($: Engine, session: SessionTarget) {
89
+ const info = (await readJson($, `${session.moduleDir}/_moduleInfo.json`)) as { url?: unknown } | undefined;
90
+ if (info?.url) return httpsUrl(info.url);
91
+ const list = await readJson($, `${session.workspaceRoot}/${session.projectId}/modules/_modules.json`);
92
+ const row = Array.isArray(list) ? list.find((item) => item?.ModuleID === session.moduleId) : undefined;
93
+ return httpsUrl(row?.url);
94
+ }
95
+
96
+ async function projectUrl($: Engine, session: SessionTarget) {
97
+ const info = (await readJson($, `${session.workspaceRoot}/${session.projectId}/_projectInfo.json`)) as
98
+ | { pDomain?: unknown }
99
+ | undefined;
100
+ return httpsUrl(firstDomain(info?.pDomain));
101
+ }
102
+
103
+ async function sync($: Engine, command: string, lastCommand: string) {
104
+ const state = (await readJson($, await sessionFile($, command))) as { target?: SessionTarget } | undefined;
105
+ const session = state?.target;
106
+ const found: FuiTarget = { lastCommand };
107
+ if (session?.projectId) {
108
+ found.project = session.projectId;
109
+ found.module = session.moduleId;
110
+ found.moduleDir = session.moduleDir;
111
+ found.projectUrl = await projectUrl($, session);
112
+ found.moduleUrl = session.moduleId ? await moduleUrl($, session) : undefined;
113
+ }
114
+ await update($, target, () => found);
115
+ }
116
+
117
+ let session: string | undefined;
118
+ let isStale = false;
119
+ let running: Promise<void> | undefined;
120
+
121
+ async function showDiff($: Engine, diff: FuiTarget["diff"]) {
122
+ const current: FuiTarget | null = await read($, target);
123
+ if (current?.moduleDir) await update($, target, () => ({ ...current, diff }));
124
+ }
125
+
126
+ function refreshDiff($: Engine) {
127
+ isStale = false;
128
+ running ??= (async () => {
129
+ do {
130
+ isStale = false;
131
+ await showDiff($, "pending");
132
+ await showDiff($, await remoteDiff($, session));
133
+ } while (isStale);
134
+ })().finally(() => {
135
+ running = undefined;
136
+ });
32
137
  }
33
138
 
34
139
  export const register: Register = (on) => {
140
+ on("prompt.submit", async ($, e, next) => {
141
+ await update($, target, () => null);
142
+ return next(e);
143
+ });
144
+
145
+ on("tool.call", async ($, e, next) => {
146
+ const result = await next(e);
147
+ if (!EDIT_TOOLS.has(e.tool)) return result;
148
+ const current: FuiTarget | null = await read($, target);
149
+ const path =
150
+ (e as { file_path?: string; notebook_path?: string }).file_path ??
151
+ (e as { notebook_path?: string }).notebook_path;
152
+ if (current?.moduleDir && path?.startsWith(`${current.moduleDir}/`)) isStale = true;
153
+ return result;
154
+ });
155
+
156
+ on("turn.complete", async ($, e, next) => {
157
+ if (isStale) refreshDiff($);
158
+ return next(e);
159
+ });
160
+
35
161
  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
36
162
  const result = await next(e);
37
163
  const call = e.command.match(FUI_CALL);
38
164
  if (!call) return result;
39
165
 
166
+ session = sessionKey(e.command);
40
167
  await sync($, e.command, [call[1], call[2]].filter(Boolean).join(" "));
168
+ if (!(call[1] === "remote" && call[2] === "diff")) refreshDiff($);
41
169
  return result;
42
170
  });
43
171
 
@@ -45,26 +173,47 @@ export const register: Register = (on) => {
45
173
  const current: FuiTarget | null = await read($, target);
46
174
  if (e.props.hasSurvey || current === null) return next(e);
47
175
 
48
- const { Box, Text } = $.ui.resolve(e);
176
+ const { Box, Text, Link } = $.ui.resolve(e);
49
177
  return (
50
178
  <Box flexDirection="row" columnGap={1}>
51
179
  <Text>🧩</Text>
52
180
  {current.project ? (
53
181
  <Text>
54
- <Text bold color="cyan">
55
- {current.project}
56
- </Text>
57
- {current.module && (
58
- <Text>
59
- <Text dimColor> › </Text>
60
- <Text bold>{current.module}</Text>
182
+ {current.projectUrl ? (
183
+ <Link href={current.projectUrl}>{current.project}</Link>
184
+ ) : (
185
+ <Text bold color="cyan">
186
+ {current.project}
61
187
  </Text>
62
188
  )}
189
+ {current.module && <Text dimColor> › </Text>}
190
+ {current.module &&
191
+ (current.moduleUrl ? (
192
+ <Link href={current.moduleUrl}>{current.module}</Link>
193
+ ) : (
194
+ <Text bold>{current.module}</Text>
195
+ ))}
63
196
  </Text>
64
197
  ) : (
65
198
  <Text dimColor>no active module</Text>
66
199
  )}
67
- {current.lastCommand && <Text dimColor> · fui {current.lastCommand}</Text>}
200
+ {current.diff === "pending" && <Text dimColor> · diff…</Text>}
201
+ {current.diff && current.diff !== "pending" && (
202
+ <Text>
203
+ <Text dimColor> · </Text>
204
+ {current.diff.files === 0 ? (
205
+ <Text dimColor>in sync</Text>
206
+ ) : (
207
+ <Text>
208
+ <Text>{`${current.diff.files} file${current.diff.files === 1 ? "" : "s"} `}</Text>
209
+ <Text color="green">{`+${current.diff.added}`}</Text>
210
+ <Text> </Text>
211
+ <Text color="red">{`−${current.diff.removed}`}</Text>
212
+ </Text>
213
+ )}
214
+ </Text>
215
+ )}
216
+ {current.lastCommand && <Text dimColor>{` · fui ${current.lastCommand}`}</Text>}
68
217
  </Box>
69
218
  );
70
219
  });
@@ -0,0 +1,18 @@
1
+ {
2
+ // Claude Code compiles hooks against its global `h`. Run /plugin-types from the repo root to write .claude/types.
3
+ "compilerOptions": {
4
+ "target": "es2023",
5
+ "lib": ["es2023"],
6
+ "types": [],
7
+ "module": "esnext",
8
+ "moduleResolution": "bundler",
9
+ "strict": true,
10
+ "noUncheckedIndexedAccess": true,
11
+ "noEmit": true,
12
+ "skipLibCheck": true,
13
+ "jsx": "react",
14
+ "jsxFactory": "h",
15
+ "jsxFragmentFactory": "Fragment"
16
+ },
17
+ "include": ["../../.claude/types", "hooks", "types"]
18
+ }
@@ -1,4 +1,14 @@
1
- export type FuiTarget = { project?: string; module?: string; lastCommand?: string };
1
+ export type FuiDiff = { files: number; added: number; removed: number };
2
+
3
+ export type FuiTarget = {
4
+ project?: string;
5
+ module?: string;
6
+ moduleDir?: string;
7
+ projectUrl?: string;
8
+ moduleUrl?: string;
9
+ lastCommand?: string;
10
+ diff?: FuiDiff | "pending";
11
+ };
2
12
 
3
13
  declare module "claude-code" {
4
14
  interface PluginState {