@fui-org/fui-cli 0.1.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 ADDED
@@ -0,0 +1,98 @@
1
+ # fui
2
+
3
+ A token-lean CLI for the FUI low-code platform, built with Bun. It is the successor to the Fui MCP server (`@fui-org/fui-mcp`).
4
+
5
+ v1 covers the database workflow:
6
+
7
+ - connections
8
+ - SQL
9
+ - schema
10
+ - stored procedures
11
+
12
+ It shares the Fui MCP workspace (`~/.fui-mcp/workspaces`, or `$FUI_MCP_WORKDIR`), so existing connections work immediately.
13
+
14
+ Design: [docs/specs/2026-09-28-fui-cli-v1-design.md](docs/specs/2026-09-28-fui-cli-v1-design.md)
15
+
16
+ ## Install
17
+
18
+ Requires [Bun](https://bun.com) 1.4 or later.
19
+
20
+ ```bash
21
+ bun add -g @fui-org/fui-cli
22
+ ```
23
+
24
+ This puts `fui` on your `PATH` (in `~/.bun/bin`). To run it once without installing:
25
+
26
+ ```bash
27
+ bunx --package @fui-org/fui-cli fui --help
28
+ ```
29
+
30
+ Update with `bun update -g @fui-org/fui-cli`. Remove with `bun remove -g @fui-org/fui-cli`.
31
+
32
+ ### From source
33
+
34
+ ```bash
35
+ bun install
36
+ bun link
37
+ ```
38
+
39
+ `bun link` points `fui` at `src/cli.ts`, so source edits apply immediately.
40
+
41
+ ### Build outputs
42
+
43
+ - `bun run build` writes `dist/fui.js`, a single minified bundle for Bun. This is the file the npm package ships; `prepack` runs it automatically.
44
+ - `bun run build:bin` writes `dist/fui`, a standalone executable that runs without Bun installed.
45
+
46
+ ### Release
47
+
48
+ ```bash
49
+ bun test && bun run typecheck && bun run lint && bun run format:check
50
+ bun publish
51
+ ```
52
+
53
+ `bun publish` packs `dist/fui.js`, `skills/` and `README.md`, and needs npm access to the `@fui-org` scope.
54
+
55
+ ## Use with an AI agent
56
+
57
+ ```bash
58
+ fui skill install
59
+ ```
60
+
61
+ This writes `~/.claude/skills/fui/SKILL.md`, which teaches the agent the commands, output format, exit codes and safety rules. `fui skill` prints the same guide.
62
+
63
+ ## Quick tour
64
+
65
+ ```bash
66
+ fui db list
67
+ fui query "SELECT TOP 5 * FROM tblX" --db lhu-docs/docs
68
+ fui schema --filter student
69
+ fui sp get spAPI_Student_Select
70
+ fui sp deploy spAPI_Student_Select
71
+ ```
72
+
73
+ ## Connections
74
+
75
+ A dbToken is Base64 of a connection string. The engine is detected from the token:
76
+
77
+ | Connection string | Engine | Transport |
78
+ | --------------------------------------------- | --------------- | ------------------------------------------- |
79
+ | `Server=...; Database=...; UID=...; PWD=...;` | MSSQL | tAPI over HTTPS, the same path Fui MCP uses |
80
+ | `postgres://...` | Postgres | direct, through `Bun.SQL` |
81
+ | `mysql://...`, `mariadb://...` | MySQL / MariaDB | direct, through `Bun.SQL` |
82
+ | `sqlite://...`, `file:...` | SQLite | direct, through `Bun.SQL` |
83
+
84
+ Secrets are read only from stdin (`-`) or from an env var (`--*-env VAR`):
85
+
86
+ ```bash
87
+ printf %s "$TOKEN" | fui db add docs -p lhu-docs --token - --api-domain tapi.lhu.edu.vn
88
+ fui db add local -p demo --conn-env PG_URL
89
+ ```
90
+
91
+ ## Development
92
+
93
+ ```bash
94
+ bun test
95
+ bun run typecheck
96
+ bun run lint
97
+ bun run format:check
98
+ ```
package/dist/fui.js ADDED
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+ import{parseArgs as Yt}from"util";var T={Ok:0,Runtime:1,Usage:2,Guard:3};class a extends Error{name="UsageError"}class x extends Error{name="GuardError"}function ue(e){if(e instanceof a)return T.Usage;if(e instanceof x)return T.Guard;return T.Runtime}function G(e){return new TextEncoder().encode(e).toBase64()}function J(e){try{return new TextDecoder("utf-8",{fatal:!0}).decode(Uint8Array.fromBase64(e.trim()))}catch{throw new a("token is not valid Base64 UTF-8")}}function de(e,t,n,o){return G(`Server=${e}; Database=${t}; UID=${n}; PWD=${o}; Encrypt=True; TrustServerCertificate=True;`)}function pe(e){let t=new Map;for(let n of e.split(";")){let o=n.indexOf("=");if(o<=0)continue;t.set(n.slice(0,o).trim().toLowerCase(),n.slice(o+1).trim())}return t}function Ze(e){let t=e.trim();if(/^postgres(?:ql)?:\/\//i.test(t))return"postgres";if(/^(?:mysql|mariadb):\/\//i.test(t))return"mysql";if(/^(?:sqlite|file):/i.test(t))return"sqlite";let n=pe(t);if(n.has("server")||n.has("data source"))return"mssql";throw new a("unsupported connection: expected postgres://, mysql://, mariadb://, sqlite:/file: URL or MSSQL Server=...; string")}function _(e){let t=J(e);return{engine:Ze(t),plain:t}}function v(e){let{engine:t,plain:n}=_(e);if(t==="mssql"){let r=pe(n);return{engine:t,server:r.get("server")??r.get("data source")??"",database:r.get("database")??r.get("initial catalog")??"",user:r.get("uid")??r.get("user id")??""}}if(t==="sqlite")return{engine:t,server:"",database:n.replace(/^(?:sqlite|file):(?:\/\/)?/i,""),user:""};let o=new URL(n);return{engine:t,server:o.port?`${o.hostname}:${o.port}`:o.hostname,database:decodeURIComponent(o.pathname.replace(/^\//,"")),user:decodeURIComponent(o.username)}}import{mkdir as et,writeFile as tt}from"fs/promises";import{basename as me,dirname as he,extname as we,isAbsolute as nt,join as fe,relative as rt}from"path";var ge=50;function ot(e=new Date){let t=(n)=>String(n).padStart(2,"0");return`${e.getFullYear()}${t(e.getMonth()+1)}${t(e.getDate())}_${t(e.getHours())}${t(e.getMinutes())}${t(e.getSeconds())}`}function st(e,t,n){let o=rt(e.root,t),s=o!==""&&!o.startsWith("..")&&!nt(o)?o:fe("_external",me(t)),i=we(s),c=me(s,i),u=n?`_${n}`:"";return fe(e.historyRoot(),he(s),`${c}_${ot()}${u}${i}`)}async function it(e,t){let n=we(e),o=e.slice(0,e.length-n.length);for(let s=1;s<=ge;s++){let i=s===1?e:`${o}_${s}${n}`;try{return await tt(i,t,{encoding:"utf-8",flag:"wx"}),i}catch(c){if(c.code!=="EEXIST")throw c}}let r=`${o}_${ge}${n}`;return await Bun.write(r,t),r}async function R(e,t,n={}){let o=n.content;if(o===void 0){let s=Bun.file(t);if(!await s.exists())return null;o=await s.text()}let r=st(e,t,n.suffix);return await et(he(r),{recursive:!0}),it(r,o)}var at=/^[A-Za-z0-9_-]+$/,ct=/^[A-Za-z0-9_.-]+$/;function lt(e){if(!at.test(e))throw new a(`invalid alias "${e}": use letters, digits, "_" or "-"`);return e}function U(e){if(!ct.test(e)||e.startsWith("_")||e.startsWith("."))throw new a(`invalid project id "${e}"`);return e}function be(e){let t=e.split("/");if(t.length===1)return{alias:t[0]};if(t.length===2&&t[0]&&t[1])return{project:t[0],alias:t[1]};throw new a(`invalid --db "${e}": use <alias> or <project>/<alias>`)}function P(e,t){let n=t.toLowerCase();return Object.keys(e.connections).find((o)=>o.toLowerCase()===n)??null}class Q{paths;constructor(e){this.paths=e}async load(e){let t=this.paths.connectionsFile(e),n=Bun.file(t);if(!await n.exists())return{connections:{}};let o=await n.text();if(!o.trim())return{connections:{}};let r;try{r=JSON.parse(o)}catch(i){throw Error(`${t} is not valid JSON (${i.message}); fix it by hand before writing`)}if(!r||typeof r!=="object"||Array.isArray(r))throw Error(`${t} must be an object { default, connections }`);let s=r;if(s.connections===void 0)return{...s,connections:{}};if(!s.connections||typeof s.connections!=="object"||Array.isArray(s.connections))throw Error(`${t}: "connections" must be an object of alias -> config`);return s}async write(e,t){let n=this.paths.connectionsFile(e);await R(this.paths,n),await Bun.write(n,JSON.stringify(t,null,2))}async save(e,t,n,o){let r=await this.load(e),s=P(r,t)??lt(t);if(r.connections[s]=n,o||!r.default||!P(r,r.default))r.default=s;return await this.write(e,r),s}async setDefault(e,t){let n=await this.load(e),o=P(n,t);if(!o)throw new a(this.menu(e,n,`no connection "${t}" in project "${e}"`));return n.default=o,await this.write(e,n),o}async listProjects(){let e=[],t=new Bun.Glob("*/_db/_connections.json");try{for await(let n of t.scan({cwd:this.paths.root,onlyFiles:!0})){let o=n.split(/[\\/]/)[0];if(!o.startsWith("_"))e.push(o)}}catch(n){if(n.code!=="ENOENT")throw n}return e.sort()}async resolveProject(e){let t=e.db?be(e.db):void 0,n=t?.project??e.project??Bun.env.FUI_PROJECT;if(n)return U(n);let o=await this.listProjects();if(t){let s=[];for(let i of o)if(P(await this.load(i),t.alias))s.push(i);if(s.length===1)return s[0];if(s.length===0)throw new a(`no project has connection "${t.alias}"; run: fui db list`);throw new a(`connection "${t.alias}" exists in several projects; use one of:
4
+ `+s.map((i)=>` --db ${i}/${t.alias}`).join(`
5
+ `))}let r=[];for(let s of o)if(Object.keys((await this.load(s)).connections).length>0)r.push(s);if(r.length===1)return r[0];if(r.length===0)throw new a("no DB connections in workspace; run: fui db add <alias> -p <project> --token -");throw new a(`several projects have connections; pass -p <project> or --db <project>/<alias>:
6
+ `+r.map((s)=>` -p ${s}`).join(`
7
+ `))}async resolve(e){let t=await this.resolveProject(e),n=await this.load(t);if(Object.keys(n.connections).length===0)throw new a(`project "${t}" has no DB connections; run: fui db add <alias> -p ${t}`);let r=await this.pickAlias(t,n,e),s=n.connections[r];if(!s.dbToken)throw new a(`connection ${t}/${r} has no dbToken; re-add it`);let{engine:i}=_(s.dbToken);if(i==="mssql"&&!s.apiDomain)throw new a(`connection ${t}/${r} is MSSQL and needs apiDomain; re-add with --api-domain`);return{projectId:t,alias:r,config:s,engine:i,dbToken:s.dbToken}}async pickAlias(e,t,n){let o=Object.keys(t.connections);if(n.db){let{alias:s}=be(n.db),i=P(t,s);if(!i)throw new a(this.menu(e,t,`no connection "${s}" in project "${e}"`));return i}if(o.length===1)return o[0];if(n.objectName){let s=n.objectName.toLowerCase(),i=[];for(let c of o)if((await this.knownObjectNames(e,c)).has(s))i.push(c);if(i.length===1)return i[0];throw new a(this.menu(e,t,i.length===0?`cannot infer connection for "${n.objectName}"`:`"${n.objectName}" exists in several connections`))}if(n.write)throw new a(this.menu(e,t,`project "${e}" has several connections; writes need --db`));let r=t.default?P(t,t.default):null;if(r)return r;throw new a(this.menu(e,t,`project "${e}" has several connections and no default`))}async knownObjectNames(e,t){let n=new Set,o=Bun.file(this.paths.schemaFile(e,t));if(await o.exists())try{let r=await o.json();for(let s of[r.tables,r.procedures].flatMap((i)=>Array.isArray(i)?i:[])){let i=typeof s==="string"?/^CREATE\s+(?:TABLE|VIEW|PROCEDURE|FUNCTION)\s+(\S+)/i.exec(s):null;if(i)n.add(i[1].toLowerCase())}}catch{}for(let r of await this.localSqlNames(e,t))n.add(r.toLowerCase());return n}async localSqlNames(e,t){let n=[];try{for await(let o of new Bun.Glob("*.sql").scan({cwd:this.paths.connDir(e,t)}))n.push(o.slice(0,-4))}catch(o){if(o.code!=="ENOENT")throw o}return n.sort()}menu(e,t,n){let o=Object.keys(t.connections).map((r)=>` --db ${e}/${r}${t.default===r?" (default)":""}`);return[n,...o].join(`
8
+ `)}}import{homedir as ut}from"os";import{join as C}from"path";var dt=C(ut(),".fui-mcp","workspaces");function ye(e){return e||Bun.env.FUI_MCP_WORKDIR||dt}class K{root;constructor(e){this.root=e}projectDir(e){return C(this.root,e)}dbDir(e){return C(this.projectDir(e),"_db")}connectionsFile(e){return C(this.dbDir(e),"_connections.json")}connDir(e,t){return C(this.dbDir(e),t)}schemaFile(e,t){return C(this.connDir(e,t),"schema.json")}spFile(e,t,n){return C(this.connDir(e,t),`${n}.sql`)}historyRoot(){return C(this.root,"_history")}}var Ee={project:{type:"string",short:"p"},db:{type:"string"},workdir:{type:"string"},json:{type:"boolean"},help:{type:"boolean",short:"h"}};function Te(e,t){let n=new K(ye(e.workdir));return{paths:n,store:new Q(n),values:e,positionals:t,json:e.json===!0}}function l(e,t){let n=e[t];return typeof n==="string"?n:void 0}function Se(e){return e?.code==="EPIPE"}process.stdout.on("error",(e)=>{if(Se(e))process.exit(0);throw e});function f(e){try{process.stdout.write(e)}catch(t){if(Se(t))process.exit(0);throw t}}function w(e){process.stderr.write(e.endsWith(`
9
+ `)?e:`${e}
10
+ `)}async function Ce(){return Bun.stdin.text()}async function V(e,t){if(t&&e)throw new a("pass SQL as an argument or with -f, not both");let n;if(t){let o=Bun.file(t);if(!await o.exists())throw new a(`file not found: ${t}`);n=await o.text()}else if(e==="-")n=await Ce();else if(e!==void 0)n=e;else throw new a('missing SQL: pass "<sql>", -f <file>, or - for stdin');if(!n.trim())throw new a("SQL is empty");return n}async function O(e,t,n){if(e!==void 0&&t!==void 0)throw new a(`pass ${n} or ${n}-env, not both`);if(t!==void 0){let r=Bun.env[t];if(!r)throw new a(`env var ${t} is empty or unset`);return r.trim()}if(e===void 0)return;if(e!=="-")throw new a(`${n} takes "-" (stdin) only; use ${n}-env VAR for env vars`);let o=(await Ce()).trim();if(!o)throw new a(`${n}: stdin is empty`);return o}var{SQL:pt}=globalThis.Bun;class X{engine;sql;constructor(e,t){this.engine=e;this.sql=new pt(t)}async query(e){return[...await this.sql.unsafe(e)]}async exec(e){return{count:(await this.sql.unsafe(e)).count??null}}close(){return this.sql.close()}}function Y(e){return e.trim().replace(/^https?:\/\//i,"").replace(/\/.*$/,"").toLowerCase()}function L(e){let t=Y(e);return`${/^(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/.test(t)?"http":"https"}://${t}`}function mt(e){try{let t=JSON.parse(e),n=t.Message??t.message??t.error??t.ExceptionMessage;if(typeof n==="string"&&n)return n.slice(0,400)}catch{}return e.slice(0,400)}async function Ae(e,t,n,o){let r;try{r=await fetch(`${e}${t}`,{method:"POST",headers:{"Content-Type":"application/json",authorization:`Bearer ${n}`},body:JSON.stringify({sqlcmd:o}),signal:AbortSignal.timeout(30000)})}catch(i){if(i instanceof Error&&i.name==="TimeoutError")throw Error("tAPI timed out after 30s; narrow the query with TOP/WHERE");throw i}let s=await r.text();if(!r.ok)throw Error(`tAPI HTTP ${r.status}: ${mt(s)||r.statusText}`);try{return JSON.parse(s)}catch{throw Error(`tAPI returned non-JSON: ${s.slice(0,400)}`)}}class z{origin;dbToken;engine="mssql";constructor(e,t){this.origin=e;this.dbToken=t}async query(e){return(await Ae(this.origin,"/help/_sql_execute",this.dbToken,e)).data??[]}exec(e){return Ae(this.origin,"/help/_sql_execute_NonQuery",this.dbToken,e)}async close(){}}async function Z(e,t,n,o,r){let s=`${e}/help/${t}/${n?"auth/":""}${o}`,i={};if(!n&&r)i.authorization=`Bearer ${r}`;let c=await fetch(s,{headers:i,signal:AbortSignal.timeout(15000)});return{url:s,status:c.status,ok:c.ok,text:await c.text()}}function ft(e){if(e.engine==="mssql")return new z(L(e.config.apiDomain),e.dbToken);return new X(e.engine,J(e.dbToken))}async function E(e,t){let n=ft(e);try{return await t(n)}finally{await n.close()}}function gt(e){if(e===null||e===void 0)return"NULL";if(e instanceof Date)return e.toISOString();if(e instanceof Uint8Array)return`0x${e.toHex()}`;return(typeof e==="object"?JSON.stringify(e):String(e)).replace(/\\/g,"\\\\").replace(/\t/g,"\\t").replace(/\r/g,"\\r").replace(/\n/g,"\\n")}function ke(e){let t=[...new Set(e.flatMap((n)=>Object.keys(n)))];return[t,...e.map((n)=>t.map((o)=>gt(n[o])))]}function ht(e){if(e.length===0)return"";return`${ke(e).map((t)=>t.join("\t")).join(`
11
+ `)}
12
+ `}function wt(e){if(e.length===0)return"";let t=ke(e),n=t[0].map((r,s)=>Math.max(...t.map((i)=>Bun.stringWidth(i[s]))));return`${t.map((r)=>r.map((s,i)=>i===r.length-1?s:s+" ".repeat(n[i]-Bun.stringWidth(s))).join(" ")).join(`
13
+ `)}
14
+ `}function ee(e){return`${JSON.stringify(e,(t,n)=>typeof n==="bigint"?n.toString():n)}
15
+ `}function A(e,t,n=process.stdout.isTTY===!0){if(t)return ee(e);return n?wt(e):ht(e)}function Re(e,t){let n=t>0&&e.length>t;return{rows:n?e.slice(0,t):e,total:e.length,truncated:n}}var bt={name:"db list",usage:"fui db list [-p project]",summary:"List connections across the workspace (no secrets)",async run({store:e,values:t,json:n}){let o=l(t,"project"),r=o?[U(o)]:await e.listProjects(),s=[];for(let i of r){let c=await e.load(i);for(let[u,d]of Object.entries(c.connections)){let g="invalid";try{if(d.dbToken)g=_(d.dbToken).engine}catch{}s.push({db:`${i}/${u}`,engine:g,default:c.default===u?"*":"",apiDomain:d.apiDomain??"",database:d.database??"",userToken:d.userToken?"yes":"no"})}}if(s.length===0){w("no connections; add one: fui db add <alias> -p <project> --token -");return}f(A(s,n))}},yt={name:"db add",usage:`fui db add <alias> -p <project> (--token - | --token-env VAR | --conn - | --conn-env VAR)
16
+ [--api-domain host] [--user-token-env VAR] [--default] [--no-test]`,summary:"Add or replace a connection; tests it with SELECT 1",options:{token:{type:"string"},"token-env":{type:"string"},conn:{type:"string"},"conn-env":{type:"string"},"api-domain":{type:"string"},"user-token-env":{type:"string"},default:{type:"boolean"},"no-test":{type:"boolean"}},async run({store:e,values:t,positionals:n}){let o=n[0];if(!o)throw new a("missing <alias>");let r=l(t,"project");if(!r)throw new a("missing -p <project>");let s=U(r),i=await O(l(t,"token"),l(t,"token-env"),"--token"),c=await O(l(t,"conn"),l(t,"conn-env"),"--conn");if(i===void 0===(c===void 0))throw new a("pass exactly one of --token/--token-env or --conn/--conn-env");let u=i??G(c),d=v(u),g=(await e.load(s)).connections,p=Object.entries(g).find(([k])=>k.toLowerCase()===o.toLowerCase())?.[1],m=l(t,"api-domain")??p?.apiDomain;if(d.engine==="mssql"&&!m)throw new a("MSSQL connections need --api-domain <host>");let h={...p,dbToken:u};if(m)h.apiDomain=Y(m);h.sqlServer=d.server,h.database=d.database;let b=await O(void 0,l(t,"user-token-env"),"--user-token");if(b)h.userToken=b;if(t["no-test"]!==!0){let k=Bun.nanoseconds();await E({projectId:s,alias:o,config:h,engine:d.engine,dbToken:u},(D)=>D.query("SELECT 1 AS ok")),w(`test ok ${Math.round((Bun.nanoseconds()-k)/1e6)}ms`)}let N=await e.save(s,o,h,t.default===!0);f(`saved ${s}/${N} ${d.engine} ${d.database}
17
+ `)}},Et={name:"db default",usage:"fui db default <project>/<alias>",summary:"Set the project default connection",async run({store:e,values:t,positionals:n}){let o=n[0]??l(t,"db");if(!o)throw new a("missing <project>/<alias>");let r=await e.resolveProject({db:o,project:l(t,"project")}),s=o.split("/").pop(),i=await e.setDefault(r,s);f(`default ${r}/${i}
18
+ `)}},Tt={name:"db test",usage:"fui db test [--db ref]",summary:"Run SELECT 1 and report latency",async run({store:e,values:t}){let n=await e.resolve({db:l(t,"db"),project:l(t,"project")}),o=Bun.nanoseconds();await E(n,(s)=>s.query("SELECT 1 AS ok"));let r=Math.round((Bun.nanoseconds()-o)/1e6);f(`ok ${n.projectId}/${n.alias} ${n.engine} ${r}ms
19
+ `)}},Ne=[bt,yt,Et,Tt];function S(e,t={}){let n=t.strings!==!1,o=e.split(""),r=0;while(r<e.length)if(e[r]==="-"&&e[r+1]==="-")while(r<e.length&&e[r]!==`
20
+ `)o[r++]=" ";else if(e[r]==="/"&&e[r+1]==="*"){let s=e.indexOf("*/",r+2),i=s===-1?e.length:s+2;while(r<i)o[r++]=" "}else if(e[r]==="'"){if(n)o[r]=" ";r++;while(r<e.length){if(e[r]==="'"){if(n)o[r]=" ";if(r++,e[r]==="'"){if(n)o[r]=" ";r++;continue}break}if(n)o[r]=" ";r++}}else r++;return o.join("")}function te(e){return S(e,{strings:!1})}function ne(e){let t=S(e),n=[],o=0;for(let r of t.matchAll(/;|^[ \t]*GO[ \t]*$/gim))n.push({start:o,end:r.index}),o=r.index+r[0].length;return n.push({start:o,end:e.length}),n.filter((r)=>r.end>r.start)}function re(e,t=","){let n=[],o=0,r="";for(let s of e){if(s==="(")o++;else if(s===")")o--;if(s===t&&o===0)n.push(r),r="";else r+=s}if(r.trim())n.push(r);return n}var St=/\bALTER\s+TABLE\s+(?:(?:\[[^\]]*\]|\w+)\s*\.\s*)*(?:\[[^\]]*\]|\w+)\s+(?:WITH\s+(?:CHECK|NOCHECK)\s+)?(\w+)/i,Ct=new Set(["ADD","ALTER","DROP"]);function At(e){let t=te(e),n=null;for(let o of ne(e)){let r=t.slice(o.start,o.end),s=e.slice(o.start,o.end).trim();if(/\bDROP\s+TABLE\b/i.test(r))return{severity:"block",reason:"DROP TABLE",statement:s};if(!/\bALTER\s+TABLE\b/i.test(r))continue;if(/\bDROP\s+COLUMN\b/i.test(r))return{severity:"block",reason:"ALTER TABLE ... DROP COLUMN",statement:s};if(/\bSWITCH\b/i.test(r))return{severity:"block",reason:"ALTER TABLE ... SWITCH",statement:s};let i=St.exec(r)?.[1]?.toUpperCase();if(!i||!Ct.has(i))return{severity:"block",reason:`ALTER TABLE ... ${i??"(unknown action)"}`,statement:s};if(!n&&/\bALTER\s+COLUMN\b/i.test(r))n={severity:"confirm",reason:"ALTER TABLE ... ALTER COLUMN (narrowing numeric/date precision loses data silently)",statement:s}}return n}function xe(e,t){let o=(t==="DELETE"?/\bDELETE\s+(?:TOP\s*\([^)]*\)\s*)?(?:FROM\s+)?(#{1,2}\w+|@\w+|\w+)/i:/\bUPDATE\s+(?:TOP\s*\([^)]*\)\s*)?(#{1,2}\w+|@\w+|\w+)/i).exec(e)?.[1];return Boolean(o&&(o.startsWith("#")||o.startsWith("@")))}function kt(e){let t=te(e);for(let{start:n,end:o}of ne(e)){let r=t.slice(n,o);if(/\bWHERE\b/i.test(r))continue;let s=e.slice(n,o).trim();if(/\bDELETE\b/i.test(r)&&!xe(r,"DELETE"))return{severity:"block",reason:"DELETE without WHERE",statement:s};if(/\bUPDATE\b/i.test(r)&&/\bSET\b/i.test(r)&&!xe(r,"UPDATE"))return{severity:"block",reason:"UPDATE without WHERE",statement:s}}return null}function oe(e){let t=S(e),n=[];if(/\bINSERT\b/i.test(t))n.push("INSERT");if(/\bUPDATE\b/i.test(t)&&/\bSET\b/i.test(t))n.push("UPDATE");if(/\bDELETE\b/i.test(t))n.push("DELETE");if(/\bMERGE\b/i.test(t))n.push("MERGE");if(/\bTRUNCATE\b/i.test(t))n.push("TRUNCATE");return n}function se(e,t){let n=At(e);if(n?.severity==="block")return n;let o=kt(e);if(o)return o;if(n&&!t)return n;let r=oe(e);if(r.length>0&&!t)return{severity:"confirm",reason:`writes real data (${r.join(", ")})`,statement:e.trim()};return null}var Rt=200,Pe=400;function Oe(e){let t=e.statement.length>Pe?`${e.statement.slice(0,Pe)}...`:e.statement,n=e.severity==="block"?"blocked: no flag unlocks this; the user must run it by hand":"needs --confirm-write; pass it only after the user approved this write";return new x(`${e.reason}
21
+ ${n}
22
+ ---
23
+ ${t}`)}function Nt(e){if(e===void 0)return Rt;let t=Number(e);if(!Number.isInteger(t)||t<0)throw new a("--limit must be an integer >= 0 (0 = no limit)");return t}var xt={name:"query",usage:'fui query ("<sql>" | -f file | -) [--db ref] [--limit 200] [--confirm-write] [--json]',summary:"Run SQL that returns rows; prints TSV",options:{file:{type:"string",short:"f"},limit:{type:"string"},"confirm-write":{type:"boolean"}},async run({store:e,values:t,positionals:n,json:o}){let r=await V(n[0],l(t,"file")),s=Nt(l(t,"limit")),i=t["confirm-write"]===!0,c=se(r,i);if(c)throw Oe(c);let u=await e.resolve({db:l(t,"db"),project:l(t,"project"),write:oe(r).length>0}),d=await E(u,(p)=>p.query(r)),g=Re(d,s);f(A(g.rows,o)),w(g.truncated?`# rows: ${g.rows.length} of ${g.total} (truncated; use --limit or TOP/WHERE)`:`# rows: ${g.total}`)}},Pt={name:"exec",usage:'fui exec ("<sql>" | -f file | -) [--db ref] [--confirm-write]',summary:"Run DDL/DML that returns no rows",options:{file:{type:"string",short:"f"},"confirm-write":{type:"boolean"}},async run({store:e,values:t,positionals:n,json:o}){let r=await V(n[0],l(t,"file")),s=se(r,t["confirm-write"]===!0);if(s)throw Oe(s);let i=await e.resolve({db:l(t,"db"),project:l(t,"project"),write:!0}),c=await E(i,(d)=>d.exec(r)),u=`${i.projectId}/${i.alias}`;f(o?ee({db:u,result:c}):`ok ${u} ${JSON.stringify(c)}
24
+ `)}},$e=[xt,Pt];var _e=`
25
+ SELECT
26
+ o.type AS ObjectTypeCode, o.name AS ObjectName, c.name AS ColumnName, t.name AS DataType,
27
+ c.max_length, c.precision, c.scale, c.is_nullable, c.is_identity,
28
+ CASE WHEN pk.column_id IS NOT NULL THEN 1 ELSE 0 END AS IsPrimaryKey,
29
+ fk.name AS ForeignKeyName,
30
+ OBJECT_NAME(fkc.referenced_object_id) AS RefTable,
31
+ rc.name AS RefColumn
32
+ FROM sys.objects o
33
+ INNER JOIN sys.schemas s ON o.schema_id = s.schema_id
34
+ LEFT JOIN sys.columns c ON o.object_id = c.object_id
35
+ LEFT JOIN sys.types t ON c.user_type_id = t.user_type_id
36
+ LEFT JOIN (
37
+ SELECT ic.object_id, ic.column_id FROM sys.indexes i
38
+ INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id
39
+ WHERE i.is_primary_key = 1
40
+ ) pk ON c.object_id = pk.object_id AND c.column_id = pk.column_id
41
+ LEFT JOIN sys.foreign_key_columns fkc ON c.object_id = fkc.parent_object_id AND c.column_id = fkc.parent_column_id
42
+ LEFT JOIN sys.foreign_keys fk ON fkc.constraint_object_id = fk.object_id
43
+ LEFT JOIN sys.columns rc ON fkc.referenced_object_id = rc.object_id AND fkc.referenced_column_id = rc.column_id
44
+ WHERE o.is_ms_shipped = 0 AND o.type IN ('U','V') AND s.name NOT IN ('sys','INFORMATION_SCHEMA')
45
+ AND o.name NOT LIKE 'sys%' AND o.name NOT LIKE 'dt[_]%'
46
+ AND o.name NOT LIKE 'queue[_]%' AND o.name NOT LIKE 'filestream[_]%' AND o.name NOT LIKE 'cdc.%'
47
+ ORDER BY ObjectTypeCode, ObjectName
48
+ `.trim(),Le=`
49
+ SELECT
50
+ o.type AS ObjectTypeCode, o.name AS ObjectName,
51
+ p.name AS ParameterName, pt.name AS ParameterType,
52
+ p.max_length AS ParameterMaxLength, p.precision AS ParameterPrecision
53
+ FROM sys.objects o
54
+ INNER JOIN sys.schemas s ON o.schema_id = s.schema_id
55
+ LEFT JOIN sys.parameters p ON o.object_id = p.object_id
56
+ LEFT JOIN sys.types pt ON p.user_type_id = pt.user_type_id
57
+ WHERE o.is_ms_shipped = 0 AND o.type IN ('P','FN','TF','IF') AND s.name NOT IN ('sys','INFORMATION_SCHEMA')
58
+ AND o.name NOT LIKE 'sys%' AND o.name NOT LIKE 'dt[_]%'
59
+ AND o.name NOT LIKE 'queue[_]%' AND o.name NOT LIKE 'filestream[_]%' AND o.name NOT LIKE 'cdc.%'
60
+ AND o.name NOT IN ('fn_diagramobjects','sp_alterdiagram','sp_creatediagram','sp_dropdiagram',
61
+ 'sp_helpdiagramdefinition','sp_helpdiagrams','sp_renamediagram','sp_upgraddiagrams')
62
+ ORDER BY ObjectTypeCode, ObjectName
63
+ `.trim(),Ot=new Set(["nvarchar","nchar","ntext"]);function Ie(e,t,n,o){let r=e.toLowerCase();if(t===-1)return`${e}(max)`;if(r==="decimal"||r==="numeric")return o!==void 0?`${e}(${n},${o})`:`${e}(${n})`;let s=Ot.has(r)?t/2:t;return s>0?`${e}(${s})`:e}function $t(e){let t=[String(e.ColumnName),Ie(String(e.DataType??""),Number(e.max_length??0),Number(e.precision??0),Number(e.scale??0)),e.is_nullable?"NULL":"NOT NULL"];if(e.IsPrimaryKey)t.push("PRIMARY KEY");if(e.is_identity)t.push("IDENTITY");if(e.ForeignKeyName)t.push(`REFERENCES ${String(e.RefTable??"")}(${String(e.RefColumn??"")})`);return t.join(" ")}function _t(e,t){if(t)return"Function";if(/^spAPIFILE_/i.test(e))return"API File";if(/^spAPI_/i.test(e))return"API";return"Procedure"}function je(e,t){let n=new Map;for(let r of e){let s=String(r.ObjectName??"");if(!s)continue;let i=n.get(s);if(!i)i={keyword:String(r.ObjectTypeCode??"").trim()==="V"?"VIEW":"TABLE",columns:[]},n.set(s,i);if(r.ColumnName)i.columns.push($t(r))}let o=new Map;for(let r of t){let s=String(r.ObjectName??""),i=String(r.ObjectTypeCode??"").trim();if(!s||!["P","FN","TF","IF"].includes(i))continue;let c=o.get(s);if(!c)c={keyword:i==="P"?"PROCEDURE":"FUNCTION",params:[]},o.set(s,c);if(r.ParameterName){let u=Ie(String(r.ParameterType??""),Number(r.ParameterMaxLength??0),Number(r.ParameterPrecision??0));c.params.push(`@${String(r.ParameterName).replace(/^@/,"")} ${u}`)}}return{fetchedAt:new Date().toISOString(),tables:[...n].map(([r,s])=>`CREATE ${s.keyword} ${r} (${s.columns.join(",")});`),procedures:[...o].map(([r,s])=>`CREATE ${s.keyword} ${r} (${s.params.join(",")});`)}}function De(e){if(!e||typeof e!=="object")return!1;let t=e,n=(o)=>Array.isArray(o)&&o.every((r)=>typeof r==="string");return n(t.tables)&&n(t.procedures)}function F(e){return/^CREATE\s+(?:TABLE|VIEW|PROCEDURE|FUNCTION)\s+(\S+)/i.exec(e)?.[1]??null}function q(e){let t=/^CREATE\s+(TABLE|VIEW|PROCEDURE|FUNCTION)\s+(\S+)/i.exec(e);if(!t)return"Unknown";let n=t[1].toUpperCase();if(n==="TABLE")return"Table";if(n==="VIEW")return"View";return _t(t[2],n==="FUNCTION")}function ie(e){let t=e.match(/^(spAPIFILE_UPLOAD_|spAPIFILE_|spAPI_)(AUTH_)?(.+)$/i);if(!t)return null;return{prefix:t[1],isAuth:Boolean(t[2]),fnName:t[3]}}function ve(e){let t=S(e).match(/\b(?:CREATE|ALTER)\s+(?:PROCEDURE|PROC|FUNCTION)\s+(?:(?:\[[^\]]+\]|\w+)\s*\.\s*){0,2}(?:\[([^\]]+)\]|(\w+))/i);if(!t)return null;return(t[1]??t[2])||null}function Ue(e){return/\b(?:CREATE|ALTER)\s+FUNCTION\b/i.test(S(e))}function Fe(e,t){let n={sql:e,changed:!1,from:null,to:null};if(t===null)return n;let o=[...S(e).matchAll(/\b(CREATE|ALTER)(\s+)(?:PROCEDURE|PROC|FUNCTION)\b/gi)];if(o.length!==1)return n;let r=o[0],s=r[1].toUpperCase(),i=t?"ALTER":"CREATE";if(s===i)return n;return{sql:e.slice(0,r.index)+i+e.slice(r.index+r[1].length),changed:!0,from:s,to:i}}function qe(e){let t=S(e),n=t.match(/(?:CREATE|ALTER)\s+(?:PROCEDURE|PROC|FUNCTION)\s+(?:(?:\[[^\]]+\]|\w+)\s*\.\s*){0,2}(?:\[[^\]]+\]|\w+)\s*\(?/i);if(!n||n.index===void 0)return[];let o=n.index+n[0].length,r=n[0].endsWith("(")?1:0,s=t.length,i=/\bAS\b/gi;i.lastIndex=o;for(let p=i.exec(t);p;p=i.exec(t)){let m=t.slice(o,p.index);if((m.match(/\(/g)??[]).length+r===(m.match(/\)/g)??[]).length){s=p.index;break}}let c=(p)=>p.replace(/^\(/,"").replace(/\)\s*$/,""),u=re(c(t.slice(o,s))),d=re(c(e.slice(o,s))),g=[];return u.forEach((p,m)=>{if(/^\s*@\w+[\s\S]*=/.test(p))g.push((d[m]??p).trim())}),g}function I(e){return`N'${e.replace(/'/g,"''")}'`}function M(e){return`GRANT EXECUTE ON [dbo].[${e}] TO [public] AS [dbo]`}function Me(e){return`SELECT granted = COUNT(*) FROM sys.database_permissions p JOIN sys.database_principals u ON u.principal_id = p.grantee_principal_id WHERE p.class = 1 AND p.major_id = OBJECT_ID(${I(e)}) AND p.permission_name = 'EXECUTE' AND p.state IN ('G','W') AND u.name = 'public'`}var Lt=/^[A-Za-z0-9_]+$/,Be=50;function j(e,t){if(e.engine!=="mssql")throw new a(`${t} supports MSSQL connections only; ${e.alias} is ${e.engine}`)}async function It(e,t){let n=new Map;for(let o=0;o<t.length;o+=Be){let r=t.slice(o,o+Be),s=await e.query(`SELECT o.name AS name, ISNULL(OBJECT_DEFINITION(o.object_id), '') AS sqlText FROM sys.objects o WHERE o.name IN (${r.map(I).join(",")})`);for(let i of s){let c=String(i.sqlText??"");if(i.name&&c.trim())n.set(String(i.name).toLowerCase(),c)}}return n}async function We(e,t){j(t,"schema pull");let{projectId:n,alias:o}=t;return E(t,async(r)=>{let[s,i]=await Promise.all([r.query(_e),r.query(Le)]),c=je(s,i);await Bun.write(e.paths.schemaFile(n,o),JSON.stringify(c,null,2));let u=(await e.localSqlNames(n,o)).filter((m)=>Lt.test(m)),d=await It(r,u),g=[],p=[];for(let m of u){let h=d.get(m.toLowerCase());if(h===void 0){p.push(`${m}.sql`);continue}let b=e.paths.spFile(n,o,m);if(await Bun.file(b).text()===h)continue;await R(e.paths,b),await Bun.write(b,h),g.push(`${m}.sql`)}return{schema:c,refreshed:g,localOnly:p}})}async function ae(e,t){let n=Bun.file(e.paths.schemaFile(t.projectId,t.alias));if(await n.exists()){try{let o=await n.json();if(De(o))return o}catch{}w("# schema cache unreadable; pulling")}else w("# no schema cache; pulling");return(await We(e,t)).schema}function jt(e){let t=new Map;for(let n of[...e.tables,...e.procedures]){let o=q(n);t.set(o,(t.get(o)??0)+1)}return[...t].map(([n,o])=>`${n}=${o}`).join(" ")}var Dt={name:"schema",usage:"fui schema [--db ref] [--filter text] [--tables | --procs]",summary:"Print compact DDL (one line per object) from the local cache",options:{filter:{type:"string"},tables:{type:"boolean"},procs:{type:"boolean"}},async run({store:e,values:t}){let n=await e.resolve({db:l(t,"db"),project:l(t,"project")});j(n,"schema");let o=await ae(e,n),r=l(t,"filter")?.toLowerCase(),s=t.tables===!0,c=[...t.procs===!0?[]:o.tables,...s?[]:o.procedures].filter((u)=>!r||(F(u)??"").toLowerCase().includes(r));if(w(`# ${n.projectId}/${n.alias} fetchedAt=${o.fetchedAt} objects=${c.length}`),c.length>0)f(`${c.join(`
64
+ `)}
65
+ `)}},vt={name:"schema pull",usage:"fui schema pull [--db ref]",summary:"Refresh schema.json and local .sql bodies from the server (MSSQL)",async run({store:e,values:t}){let n=await e.resolve({db:l(t,"db"),project:l(t,"project")}),o=await We(e,n),r=[`ok ${n.projectId}/${n.alias} ${jt(o.schema)}`];if(o.refreshed.length>0)r.push(`refreshed (old copy in _history): ${o.refreshed.join(" ")}`);if(o.localOnly.length>0)r.push(`local only, not on server: ${o.localOnly.join(" ")}`);f(`${r.join(`
66
+ `)}
67
+ `)}},He=[Dt,vt];import{homedir as Ft}from"os";import{join as Ge}from"path";var ce="---\nname: fui\ndescription: Use the `fui` CLI to work with FUI (low-code platform) databases \u2014 list DB connections, run SQL, read schema, fetch/edit/deploy stored procedures (tAPI spAPI_*). Use instead of the Fui MCP db_* tools whenever the `fui` command is available.\n---\n\n# fui CLI\n\n`fui` is the shell replacement for the Fui MCP `db_*` tools. Run it with your shell tool. It shares the Fui MCP workspace (`~/.fui-mcp/workspaces`, or `$FUI_MCP_WORKDIR`), so connections added in either tool work in both.\n\n## Output contract\n\n- stdout carries data only. Query results are TSV with a header row; `NULL` means SQL null; tab, newline and backslash inside cells are escaped as `\\t`, `\\n`, `\\\\`.\n- stderr carries `# ...` notes (row counts, truncation) and `error: ...` / `guard: ...` messages.\n- Add `--json` for compact single-line JSON instead of TSV.\n- Exit codes: `0` ok, `1` runtime error (network/SQL), `2` usage error (read the message; it lists valid choices), `3` blocked by a safety guard.\n\n## Pick a connection\n\n```bash\nfui db list # db, engine, default, apiDomain, database, userToken\n```\n\n- `--db <alias>` or `--db <project>/<alias>`. `-p <project>` or env `FUI_PROJECT` also selects the project.\n- With no `--db`: the only connection, else the project default. `sp` commands infer the connection from the object name.\n- Writes (`exec`, write SQL in `query`) never fall back to the default when a project has several connections. Pass `--db`.\n- Engines come from the token. `mssql` goes through tAPI over HTTPS. `postgres`, `mysql` and `sqlite` connect directly with Bun.SQL. `schema` and `sp` commands are MSSQL-only.\n\n## Read data\n\n```bash\nfui query \"SELECT TOP 20 * FROM tblStudent WHERE ClassID = 5\" --db lhu-docs/docs\nfui query -f report.sql --limit 0 # --limit 0 = no row cap (default 200)\necho \"SELECT 1 AS x\" | fui query -\n```\n\nNarrow with `TOP`/`WHERE` before raising `--limit`. Every row costs tokens.\n\n## Structure (MSSQL)\n\n```bash\nfui schema --filter student # one compact DDL line per table/view/proc\nfui schema --procs --filter spAPI_ # only procedures/functions\nfui schema pull # refresh cache + local .sql bodies from server\nfui sp list --filter Student # name, kind (API, API File, Procedure, Function, local-only), local\n```\n\nPrefer `fui schema` over querying `sys.*` yourself. The cache pulls automatically the first time.\n\n## Change stored procedures (MSSQL)\n\n1. `fui sp get spAPI_Student_Select` saves the live body to `_db/<alias>/<name>.sql` and prints the path.\n For a new SP: `fui sp path spAPI_New --db <ref>` prints where to create the file.\n2. Edit that file with your file tools. `CREATE` or `ALTER` both work; deploy fixes the verb.\n3. `fui sp deploy spAPI_Student_Select` does all of this:\n - archives the live body to `_history/` as the rollback copy\n - runs the file\n - grants `EXECUTE` to `[public]` for new procedures and verifies the grant\n - clears the tAPI parameter cache for `spAPI_*` names\n4. If the output says the grant failed, do **not** redeploy. Run the printed `fui exec \"GRANT ...\"` line.\n\n`fui sp help spAPI_X` shows the tAPI parameter list and clears its cache without redeploying.\n\n## Write data (needs the user)\n\n```bash\nfui exec \"UPDATE tblX SET Status = 1 WHERE ID = 42\" --db lhu-docs/docs --confirm-write\n```\n\n- Any `INSERT`/`UPDATE`/`DELETE`/`MERGE`/`TRUNCATE` or `ALTER COLUMN` exits `3` until `--confirm-write` is added.\n- Add `--confirm-write` **only after the user approved that exact write in chat**. Never add it on your own to get past the guard.\n- These are always blocked, with no flag to unlock them: `DROP TABLE`, `ALTER TABLE ... DROP COLUMN`/`SWITCH`, and `DELETE`/`UPDATE` without `WHERE`. Show the SQL to the user to run by hand.\n- Never run a write or call a live API SP \"to test\" it. Read the SP body instead.\n\n## FUI conventions that matter here\n\n- tAPI exposes `spAPI_<Fn>` as `/<alias>/<Fn>`, `spAPI_AUTH_<Fn>` as `/<alias>/auth/<Fn>`, and `spAPIFILE_*` for file endpoints. The alias is the connection name.\n- tAPI SP parameters must not have default values (`@x int = 0`). Deploy blocks them with exit `3`.\n- Signal errors in SPs with `RAISERROR`. Callers read `Message`.\n- Every new procedure needs `GRANT EXECUTE ... TO [public]`. Deploy handles it; raw `fui exec CREATE PROCEDURE` does not.\n\n## Connections and tokens\n\n```bash\nprintf %s \"$TOKEN\" | fui db add docs -p lhu-docs --token - --api-domain tapi.lhu.edu.vn\nfui db add local -p demo --conn-env PG_URL # postgres://user:pass@host/db\nfui db add cache -p demo --conn-env SQLITE_URL # sqlite:///abs/path/app.db\nfui db default lhu-docs/docs\nfui db test --db lhu-docs/docs\nfui token decode --db lhu-docs/docs # engine, server, database, user (no password)\nfui token encode --server S --database D --uid U --pwd-env PWD # Base64 MSSQL dbToken\n```\n\n- A dbToken is Base64 of a connection string.\n- Secrets are accepted only from stdin (`-`) or `--*-env VAR`. Never echo, log or paste token values.\n- `db add` tests with `SELECT 1` before saving (`--no-test` skips the test). The previous `_connections.json` is archived to `_history/`.\n\n## Help\n\n`fui --help` lists commands. `fui <command> --help` shows usage for one command.\n";var qt={name:"skill",usage:"fui skill",summary:"Print the agent skill (how to use this CLI)",async run(){f(ce)}},Mt={name:"skill install",usage:"fui skill install [--dir <dir>]",summary:"Write SKILL.md to ~/.claude/skills/fui/ (or --dir)",options:{dir:{type:"string"}},async run({values:e}){let t=l(e,"dir")??Ge(Ft(),".claude","skills","fui"),n=Ge(t,"SKILL.md");await Bun.write(n,ce),f(`${n}
68
+ `)}},Je=[qt,Mt];var Bt=/^[A-Za-z0-9_]+$/,Qe=2000;function B(e){let t=e[0];if(!t)throw new a("missing <name>");if(!Bt.test(t))throw new a(`invalid object name "${t}": letters, digits and "_" only`);return t}async function W({store:e,values:t},n){let o=await e.resolve({db:l(t,"db"),project:l(t,"project"),objectName:n});return j(o,"sp"),o}async function Ke(e,t){let n=await e.query(`SELECT ISNULL(OBJECT_DEFINITION(OBJECT_ID(${I(t)})), '') AS sqlText`);return String(n[0]?.sqlText??"")}async function Wt(e,t){try{let n=await e.query(Me(t)),o=Number(n[0]?.granted??Number.NaN);return Number.isFinite(o)?o>0:null}catch{return null}}var Ht={name:"sp list",usage:"fui sp list [--db ref] [--filter text]",summary:"List procedures/functions from the schema cache plus local-only .sql files",options:{filter:{type:"string"}},async run(e){let{store:t,values:n,json:o}=e,r=await t.resolve({db:l(n,"db"),project:l(n,"project")});j(r,"sp list");let s=await ae(t,r),i=new Set((await t.localSqlNames(r.projectId,r.alias)).map((p)=>p.toLowerCase())),c=l(n,"filter")?.toLowerCase(),u=[],d=new Set;for(let p of s.procedures){let m=F(p);if(!m)continue;d.add(m.toLowerCase()),u.push({name:m,kind:q(p),local:i.has(m.toLowerCase())?"yes":"no"})}for(let p of await t.localSqlNames(r.projectId,r.alias))if(!d.has(p.toLowerCase()))u.push({name:p,kind:"local-only",local:"yes"});let g=u.filter((p)=>!c||p.name.toLowerCase().includes(c));w(`# ${r.projectId}/${r.alias} count=${g.length}`),f(A(g,o))}},Gt={name:"sp get",usage:"fui sp get <name> [--db ref] [--print]",summary:"Fetch a definition from the server into _db/<alias>/<name>.sql",options:{print:{type:"boolean"}},async run(e){let t=B(e.positionals),n=await W(e,t),o=await E(n,(i)=>Ke(i,t));if(!o.trim())throw Error(`"${t}" not found on ${n.projectId}/${n.alias}`);let r=e.store.paths.spFile(n.projectId,n.alias,t),s=Bun.file(r);if(await s.exists()&&await s.text()!==o)w(`# old local copy archived: ${await R(e.store.paths,r)}`);await Bun.write(r,o),f(e.values.print===!0?o:`${r}
69
+ `)}},Jt={name:"sp path",usage:"fui sp path <name> [--db ref]",summary:"Print the local .sql path to edit (file may not exist yet)",async run(e){let t=B(e.positionals),n=await W(e,t);f(`${e.store.paths.spFile(n.projectId,n.alias,t)}
70
+ `)}},Qt={name:"sp deploy",usage:"fui sp deploy <name> [--db ref]",summary:"Deploy the local .sql: archive live body, align CREATE/ALTER, grant, clear tAPI cache",async run(e){let t=B(e.positionals),n=await W(e,t),{projectId:o,alias:r}=n,s=e.store.paths,i=s.spFile(o,r,t),c=Bun.file(i);if(!await c.exists())throw new a(`file not found: ${i}; run: fui sp get ${t} or create it`);let u=await c.text();if(!u.trim())throw new a(`file is empty: ${i}`);let d=ve(u)??t,g=ie(d);if(g){let h=qe(u);if(h.length>0)throw new x(`tAPI SP parameters cannot have default values; remove "= ..." then deploy again:
71
+ `+h.map((b)=>` ${b}`).join(`
72
+ `))}let p=`${o}/${r}`,m=[];await E(n,async(h)=>{let b=null,N="";try{N=(await Ke(h,d)).trim(),b=N.length>0}catch{}if(N){let y=await R(s,i,{content:N});m.push(`rollback copy: ${y}`)}let k=Fe(u,b);await h.exec(k.sql),m.unshift(`ok deployed ${d} to ${p}${k.changed?` (ran as ${k.to}, file unchanged)`:""}`);let D=Ue(u),le=b===null?/\bCREATE\s+(?:PROC|PROCEDURE)\b/i.test(u):!b;if(le&&!D)try{await h.exec(M(d)),m.push("grant EXECUTE to [public]: applied")}catch(y){m.push(`grant FAILED (${y.message}); do not redeploy, run: fui exec "${M(d)}" --db ${p}`)}if(!D){let y=await Wt(h,d);if(y===!1)m.push(`[public] lacks EXECUTE; tAPI calls will fail. run: fui exec "${M(d)}" --db ${p}`);else if(y===!0&&!le)m.push("[public] has EXECUTE")}if(g)try{let y=await Z(L(n.config.apiDomain),r,g.isAuth,g.fnName,n.config.userToken);m.push(y.ok?`tAPI cache cleared (HTTP ${y.status})`:`tAPI /help HTTP ${y.status}; ignored`)}catch(y){m.push(`tAPI /help failed (${y.message}); ignored`)}}),f(`${m.join(`
73
+ `)}
74
+ `)}},Kt={name:"sp help",usage:"fui sp help <name> [--db ref]",summary:"Call tAPI /help for an spAPI_* SP: shows params and clears the tAPI cache",async run(e){let t=B(e.positionals),n=ie(t);if(!n)throw new a(`"${t}" is not a tAPI SP (spAPI_*, spAPIFILE_*)`);let o=await W(e,t),r=await Z(L(o.config.apiDomain),o.alias,n.isAuth,n.fnName,o.config.userToken),s=r.text.length>Qe?`${r.text.slice(0,Qe)}...`:r.text;if(w(`# GET ${r.url} -> HTTP ${r.status}`),f(`${s}
75
+ `),!r.ok)process.exitCode=1}},Ve=[Ht,Gt,Jt,Qt,Kt];var Vt={name:"token decode",usage:"fui token decode [--db ref]",summary:"Show engine, server, database and user of a stored token (password never shown)",async run({store:e,values:t,json:n}){let o=await e.resolve({db:l(t,"db"),project:l(t,"project")}),r={db:`${o.projectId}/${o.alias}`,...v(o.dbToken)};f(A([r],n))}},Xt={name:"token encode",usage:"fui token encode --server S --database D --uid U --pwd-env VAR",summary:"Print a Fui MSSQL dbToken (Base64) built like Fui MCP",options:{server:{type:"string"},database:{type:"string"},uid:{type:"string"},"pwd-env":{type:"string"}},async run({values:e}){let t=l(e,"server"),n=l(e,"database"),o=l(e,"uid"),r=await O(void 0,l(e,"pwd-env"),"--pwd");if(!t||!n||!o||!r)throw new a("need --server, --database, --uid and --pwd-env");f(`${de(t,n,o,r)}
76
+ `)}},Xe=[Vt,Xt];var H=[...Ne,...Xe,...$e,...He,...Ve,...Je],ze=`global: -p/--project <id> --db <alias|project/alias> --workdir <dir> --json -h/--help
77
+ exit: 0 ok, 1 runtime error, 2 usage error, 3 guard blocked`;function Ye(){let e=Math.max(...H.map((n)=>n.name.length));return`fui: FUI platform CLI (DB)
78
+
79
+ commands:
80
+ ${H.map((n)=>` ${n.name.padEnd(e)} ${n.summary}`).join(`
81
+ `)}
82
+
83
+ ${ze}
84
+ run "fui skill" for the agent guide
85
+ `}function zt(e){return`${e.usage}
86
+ ${e.summary}
87
+ ${ze}
88
+ `}function Zt(e){let[t,n]=e;if(!t||t.startsWith("-"))return null;if(n&&!n.startsWith("-")){let r=H.find((s)=>s.name===`${t} ${n}`);if(r)return{command:r,rest:e.slice(2)}}let o=H.find((r)=>r.name===t);return o?{command:o,rest:e.slice(1)}:null}async function en(e){let t=Zt(e);if(!t){let r=e[0];if(!r||r==="-h"||r==="--help"||r==="help")return f(Ye()),T.Ok;return w(`unknown command: ${e.slice(0,2).join(" ")}
89
+ `),w(Ye()),T.Usage}let{command:n,rest:o}=t;try{let r;try{r=Yt({args:o,options:{...Ee,...n.options},allowPositionals:!0,strict:!0})}catch(s){throw new a(`${s.message}
90
+ usage: ${n.usage}`)}if(r.values.help===!0)return f(zt(n)),T.Ok;return await n.run(Te(r.values,r.positionals)),typeof process.exitCode==="number"?process.exitCode:T.Ok}catch(r){let s=ue(r),i=s===T.Guard?"guard":"error";return w(`${i}: ${r.message}`),s}}if(import.meta.main)process.exitCode=await en(Bun.argv.slice(2));export{en as main};
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@fui-org/fui-cli",
3
+ "version": "0.1.0",
4
+ "description": "Token-lean CLI for the FUI low-code platform, successor to Fui MCP",
5
+ "keywords": [
6
+ "ai",
7
+ "bun",
8
+ "cli",
9
+ "fui",
10
+ "low-code"
11
+ ],
12
+ "license": "MIT",
13
+ "bin": {
14
+ "fui": "dist/fui.js"
15
+ },
16
+ "files": [
17
+ "dist/fui.js",
18
+ "skills",
19
+ "README.md"
20
+ ],
21
+ "type": "module",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "scripts": {
26
+ "fui": "bun src/cli.ts",
27
+ "build": "bun build src/cli.ts --target bun --minify --outfile dist/fui.js",
28
+ "build:bin": "bun build --compile --minify src/cli.ts --outfile dist/fui",
29
+ "prepack": "bun run build",
30
+ "test": "bun test",
31
+ "lint": "oxlint",
32
+ "format": "oxfmt",
33
+ "format:check": "oxfmt --check",
34
+ "typecheck": "tsc --noEmit"
35
+ },
36
+ "devDependencies": {
37
+ "@types/bun": "latest",
38
+ "oxfmt": "^0.70.0",
39
+ "oxlint": "^1.85.0",
40
+ "typescript": "^7.0.2"
41
+ },
42
+ "engines": {
43
+ "bun": ">=1.4.0"
44
+ }
45
+ }
@@ -0,0 +1,99 @@
1
+ ---
2
+ name: fui
3
+ description: Use the `fui` CLI to work with FUI (low-code platform) databases — list DB connections, run SQL, read schema, fetch/edit/deploy stored procedures (tAPI spAPI_*). Use instead of the Fui MCP db_* tools whenever the `fui` command is available.
4
+ ---
5
+
6
+ # fui CLI
7
+
8
+ `fui` is the shell replacement for the Fui MCP `db_*` tools. Run it with your shell tool. It shares the Fui MCP workspace (`~/.fui-mcp/workspaces`, or `$FUI_MCP_WORKDIR`), so connections added in either tool work in both.
9
+
10
+ ## Output contract
11
+
12
+ - stdout carries data only. Query results are TSV with a header row; `NULL` means SQL null; tab, newline and backslash inside cells are escaped as `\t`, `\n`, `\\`.
13
+ - stderr carries `# ...` notes (row counts, truncation) and `error: ...` / `guard: ...` messages.
14
+ - Add `--json` for compact single-line JSON instead of TSV.
15
+ - Exit codes: `0` ok, `1` runtime error (network/SQL), `2` usage error (read the message; it lists valid choices), `3` blocked by a safety guard.
16
+
17
+ ## Pick a connection
18
+
19
+ ```bash
20
+ fui db list # db, engine, default, apiDomain, database, userToken
21
+ ```
22
+
23
+ - `--db <alias>` or `--db <project>/<alias>`. `-p <project>` or env `FUI_PROJECT` also selects the project.
24
+ - With no `--db`: the only connection, else the project default. `sp` commands infer the connection from the object name.
25
+ - Writes (`exec`, write SQL in `query`) never fall back to the default when a project has several connections. Pass `--db`.
26
+ - Engines come from the token. `mssql` goes through tAPI over HTTPS. `postgres`, `mysql` and `sqlite` connect directly with Bun.SQL. `schema` and `sp` commands are MSSQL-only.
27
+
28
+ ## Read data
29
+
30
+ ```bash
31
+ fui query "SELECT TOP 20 * FROM tblStudent WHERE ClassID = 5" --db lhu-docs/docs
32
+ fui query -f report.sql --limit 0 # --limit 0 = no row cap (default 200)
33
+ echo "SELECT 1 AS x" | fui query -
34
+ ```
35
+
36
+ Narrow with `TOP`/`WHERE` before raising `--limit`. Every row costs tokens.
37
+
38
+ ## Structure (MSSQL)
39
+
40
+ ```bash
41
+ fui schema --filter student # one compact DDL line per table/view/proc
42
+ fui schema --procs --filter spAPI_ # only procedures/functions
43
+ fui schema pull # refresh cache + local .sql bodies from server
44
+ fui sp list --filter Student # name, kind (API, API File, Procedure, Function, local-only), local
45
+ ```
46
+
47
+ Prefer `fui schema` over querying `sys.*` yourself. The cache pulls automatically the first time.
48
+
49
+ ## Change stored procedures (MSSQL)
50
+
51
+ 1. `fui sp get spAPI_Student_Select` saves the live body to `_db/<alias>/<name>.sql` and prints the path.
52
+ For a new SP: `fui sp path spAPI_New --db <ref>` prints where to create the file.
53
+ 2. Edit that file with your file tools. `CREATE` or `ALTER` both work; deploy fixes the verb.
54
+ 3. `fui sp deploy spAPI_Student_Select` does all of this:
55
+ - archives the live body to `_history/` as the rollback copy
56
+ - runs the file
57
+ - grants `EXECUTE` to `[public]` for new procedures and verifies the grant
58
+ - clears the tAPI parameter cache for `spAPI_*` names
59
+ 4. If the output says the grant failed, do **not** redeploy. Run the printed `fui exec "GRANT ..."` line.
60
+
61
+ `fui sp help spAPI_X` shows the tAPI parameter list and clears its cache without redeploying.
62
+
63
+ ## Write data (needs the user)
64
+
65
+ ```bash
66
+ fui exec "UPDATE tblX SET Status = 1 WHERE ID = 42" --db lhu-docs/docs --confirm-write
67
+ ```
68
+
69
+ - Any `INSERT`/`UPDATE`/`DELETE`/`MERGE`/`TRUNCATE` or `ALTER COLUMN` exits `3` until `--confirm-write` is added.
70
+ - Add `--confirm-write` **only after the user approved that exact write in chat**. Never add it on your own to get past the guard.
71
+ - These are always blocked, with no flag to unlock them: `DROP TABLE`, `ALTER TABLE ... DROP COLUMN`/`SWITCH`, and `DELETE`/`UPDATE` without `WHERE`. Show the SQL to the user to run by hand.
72
+ - Never run a write or call a live API SP "to test" it. Read the SP body instead.
73
+
74
+ ## FUI conventions that matter here
75
+
76
+ - tAPI exposes `spAPI_<Fn>` as `/<alias>/<Fn>`, `spAPI_AUTH_<Fn>` as `/<alias>/auth/<Fn>`, and `spAPIFILE_*` for file endpoints. The alias is the connection name.
77
+ - tAPI SP parameters must not have default values (`@x int = 0`). Deploy blocks them with exit `3`.
78
+ - Signal errors in SPs with `RAISERROR`. Callers read `Message`.
79
+ - Every new procedure needs `GRANT EXECUTE ... TO [public]`. Deploy handles it; raw `fui exec CREATE PROCEDURE` does not.
80
+
81
+ ## Connections and tokens
82
+
83
+ ```bash
84
+ printf %s "$TOKEN" | fui db add docs -p lhu-docs --token - --api-domain tapi.lhu.edu.vn
85
+ fui db add local -p demo --conn-env PG_URL # postgres://user:pass@host/db
86
+ fui db add cache -p demo --conn-env SQLITE_URL # sqlite:///abs/path/app.db
87
+ fui db default lhu-docs/docs
88
+ fui db test --db lhu-docs/docs
89
+ fui token decode --db lhu-docs/docs # engine, server, database, user (no password)
90
+ fui token encode --server S --database D --uid U --pwd-env PWD # Base64 MSSQL dbToken
91
+ ```
92
+
93
+ - A dbToken is Base64 of a connection string.
94
+ - Secrets are accepted only from stdin (`-`) or `--*-env VAR`. Never echo, log or paste token values.
95
+ - `db add` tests with `SELECT 1` before saving (`--no-test` skips the test). The previous `_connections.json` is archived to `_history/`.
96
+
97
+ ## Help
98
+
99
+ `fui --help` lists commands. `fui <command> --help` shows usage for one command.