@gizai/cli 2.2.0 → 2.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Giz Systems Inc. and the Gizlang contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/cli/giz.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { computerIdentity, managedRevision } from './computer-identity.mjs'
3
3
  import { checkForUpdate, explicitUpdate, installSource, maybeNotify } from './update.mjs'
4
- import { runToolchain, toolchainCommand } from './toolchain.mjs'
4
+ import { GROUPS, runToolchain, toolchainCommand, toolchainRoot, toolchainVersion } from './toolchain.mjs'
5
5
  import { createHash, randomBytes } from 'node:crypto'
6
- import { openAsBlob, statSync } from 'node:fs'
6
+ import { existsSync, openAsBlob, rmSync, statSync } from 'node:fs'
7
7
  import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
8
+ import { createInterface } from 'node:readline/promises'
8
9
  import { createServer } from 'node:http'
9
10
  import { once } from 'node:events'
10
11
  import { createRequire } from 'node:module'
@@ -457,6 +458,7 @@ async function runCommand(baseUrl, positionals, options) {
457
458
  await checkForUpdate({ file: options.cache, url: options.registry })
458
459
  return undefined
459
460
  }
461
+ if (command === 'uninstall') return { kind: 'message', value: options.help === true ? help('uninstall') : await uninstall(options) }
460
462
  if (command === 'update' && options.help === true) return { kind: 'help', value: help('update') }
461
463
  if (command === 'update' && !subcommand) {
462
464
  const result = await explicitUpdate({ version: CLI_VERSION, source: installSource() })
@@ -464,14 +466,15 @@ async function runCommand(baseUrl, positionals, options) {
464
466
  }
465
467
  if (options.version === true) {
466
468
  const revision = await managedRevision()
467
- return { kind: 'message', value: CLI_VERSION + (revision ? ` (region ${revision.trim()})` : '') }
469
+ const toolchain = toolchainVersion() ?? `not installed (${toolchainRoot()})`
470
+ return { kind: 'message', value: `@gizai/cli ${CLI_VERSION}${revision ? ` (region ${revision.trim()})` : ''}\nGizlang toolchain: ${toolchain}` }
468
471
  }
469
472
  if (!command || command === 'help' || options.help === true) {
470
473
  // A script that asks for help on a command that does not exist must fail, not read the overview as success.
471
474
  if (command && !gizCommand(command)) {
472
475
  throw new CliError(`Unknown command "${command}".`, 'Run: giz --help')
473
476
  }
474
- return { kind: 'help', value: help(command) }
477
+ return { kind: 'help', value: help(command, options.all === true) }
475
478
  }
476
479
  if (command === 'engine' && subcommand === 'run') {
477
480
  if (rest[0] !== 'claude') throw new CliError('Only Claude Code is an engine connection.')
@@ -938,7 +941,13 @@ function print(kind, value, json, output = process.stdout) {
938
941
  output.write(`${JSON.stringify(value, null, 2)}\n`)
939
942
  }
940
943
 
941
- function help(command) {
944
+ function help(command, all = false) {
945
+ if (command === 'uninstall') return `Usage
946
+ giz uninstall [--yes] [--credentials]
947
+
948
+ Removes the Gizlang toolchain (${toolchainRoot()}; GIZ_INSTALL names another folder) after a confirmation.
949
+ --yes skips the confirmation. --credentials also removes your giz.ai login (${configPath}).
950
+ It prints the command that removes @gizai/cli itself.`
942
951
  if (command === 'engine') return `Usage
943
952
  giz engine run claude "instruction" [--dir path]
944
953
 
@@ -1047,6 +1056,7 @@ http uses the client's own OAuth login. Install the chosen client first.`
1047
1056
  Usage
1048
1057
  giz auth login [--device]
1049
1058
  giz update
1059
+ giz uninstall [--yes] [--credentials]
1050
1060
  giz api-key list|create|revoke
1051
1061
  giz chat MODEL "your prompt"
1052
1062
  giz model list [--type TYPE] [--search TEXT]
@@ -1071,8 +1081,8 @@ Usage
1071
1081
 
1072
1082
  Use giz COMMAND --help for parameters and examples.
1073
1083
 
1074
- Gizlang (the toolchain is installed on first use, with its size shown)
1075
- init, run, build, test, check, deploy, and every other command; gizdb runs the database server.
1084
+ Gizlang (giz COMMAND; the toolchain is installed on first use, with its size shown; gizdb runs the database server)
1085
+ ${gizlangCommands(all)}
1076
1086
  Docs: https://gizstack.com/docs
1077
1087
 
1078
1088
  Global options
@@ -1084,6 +1094,41 @@ Global options
1084
1094
  Docs: https://www.giz.ai/mcp/`
1085
1095
  }
1086
1096
 
1097
+ // The toolchain's commands by group, as its own help shows them, from the generated table (toolchain.mjs GROUPS), so the
1098
+ // list needs no download. Its Repository group serves a Gizlang checkout and shows only with --all. No line starts with
1099
+ // " giz ": gizCommand reads those as giz.ai's.
1100
+ function gizlangCommands(all) {
1101
+ return GROUPS.filter((group) => all || group.name !== 'Repository').map((group) => ` ${group.name}:\n` +
1102
+ group.commands.map((command) => ` ${command.synopsis.padEnd(30)} ${command.summary}`).join('\n')).join('\n')
1103
+ }
1104
+
1105
+ // Removes the toolchain's folder and nothing else: a folder without the toolchain's program is refused.
1106
+ async function uninstall(options) {
1107
+ const root = toolchainRoot()
1108
+ const after = `\nTo remove @gizai/cli itself, run: ${installSource().remove ?? 'npm uninstall -g @gizai/cli'}`
1109
+ const removed = []
1110
+ if (existsSync(root)) {
1111
+ if (!existsSync(path.join(root, 'bin', process.platform === 'win32' ? 'giz.exe' : 'giz'))) {
1112
+ throw new CliError(`${root} does not hold the Gizlang toolchain (no bin/giz); nothing was removed.`)
1113
+ }
1114
+ if (options.yes !== true) {
1115
+ if (!process.stdin.isTTY) throw new CliError(`Removing ${root} needs a confirmation.`, 'Run: giz uninstall --yes')
1116
+ const ask = createInterface({ input: process.stdin, output: process.stderr })
1117
+ const login = options.credentials === true ? ` and your giz.ai login (${configPath})` : ''
1118
+ const answer = await ask.question(`Remove the Gizlang toolchain at ${root}${login}? [y/N] `)
1119
+ ask.close()
1120
+ if (!/^y(es)?$/i.test(answer.trim())) return `Nothing was removed.${after}`
1121
+ }
1122
+ rmSync(root, { recursive: true, force: true })
1123
+ removed.push(root)
1124
+ }
1125
+ if (options.credentials === true && existsSync(configPath)) {
1126
+ rmSync(configPath)
1127
+ removed.push(configPath)
1128
+ }
1129
+ return (removed.length ? `Removed ${removed.join(' and ')}.` : `The Gizlang toolchain is not installed (${root}).`) + after
1130
+ }
1131
+
1087
1132
  // giz.ai's commands are the ones its overview lists; every other command is the Gizlang toolchain's (toolchain.mjs).
1088
1133
  function gizCommand(name) {
1089
1134
  return ['help', 'models', '__update-check'].includes(name) ||
@@ -1108,7 +1153,7 @@ async function main() {
1108
1153
  void maybeNotify({ version: CLI_VERSION, source, mode: updateMode, env: { ...process.env, GIZ_JSON: options.json === true ? '1' : process.env.GIZ_JSON } }).catch(() => {})
1109
1154
  const requiresAuth = positionals.length && !options.help && !options.version &&
1110
1155
  !(positionals[0] === 'auth' && ['login', 'logout'].includes(positionals[1])) &&
1111
- !['update', '__update-check'].includes(positionals[0])
1156
+ !['help', 'update', 'uninstall', '__update-check'].includes(positionals[0])
1112
1157
  const computer = requiresAuth && !process.env.GIZ_TOKEN && !saved?.access_token ? await computerIdentity() : undefined
1113
1158
  const baseUrl = String(options['base-url'] || process.env.GIZ_URL || saved?.baseUrl || computer?.baseUrl || DEFAULT_URL).replace(/\/+$/, '')
1114
1159
  if (typeof options.org === 'string') process.env.GIZ_ORGANIZATION = options.org
@@ -0,0 +1,210 @@
1
+ {
2
+ "schema": "gizai.gizlang-commands.v1",
3
+ "groups": [
4
+ {
5
+ "name": "Daily work",
6
+ "commands": [
7
+ {
8
+ "name": "init",
9
+ "synopsis": "init [FLAG...] [DIR]",
10
+ "summary": "Create a Hello project: `main.giz`, its test `main.test.giz` and a `package.json` that names the entry, or with `--template NAME` a copy of an example app."
11
+ },
12
+ {
13
+ "name": "run",
14
+ "synopsis": "run [FLAG...] FILE [ARGUMENT...]",
15
+ "summary": "Build FILE (cached) and run it with the arguments."
16
+ },
17
+ {
18
+ "name": "check",
19
+ "synopsis": "check [FLAG...] FILE...",
20
+ "summary": "Check each FILE (`.giz`, `.gizgraph` or `package.json`)."
21
+ },
22
+ {
23
+ "name": "build",
24
+ "synopsis": "build [FLAG...] FILE",
25
+ "summary": "Compile FILE to a program for this CPU, and print its path."
26
+ },
27
+ {
28
+ "name": "test",
29
+ "synopsis": "test [FLAG...] [FILE... | FOLDER | FILTER]",
30
+ "summary": "Run the tests of each FILE: a test function `testName()`, or vitest's `test(\"name\", () => ...)` with `expect`."
31
+ },
32
+ {
33
+ "name": "live",
34
+ "synopsis": "live [FLAG...] FILE",
35
+ "summary": "Run FILE, and replace its code while it runs each time you save a source in its folder."
36
+ },
37
+ {
38
+ "name": "doc",
39
+ "synopsis": "doc [FLAG...] [NAME]",
40
+ "summary": "Show the documentation of NAME: a module (`gizlang:json`), a declaration (`Json.get`) or a command (`check`)."
41
+ },
42
+ {
43
+ "name": "repl",
44
+ "synopsis": "repl",
45
+ "summary": "Start the read-eval-print loop."
46
+ },
47
+ {
48
+ "name": "fmt",
49
+ "synopsis": "fmt [FLAG...] FILE...",
50
+ "summary": "Lay out each FILE by its brackets and spacing rules; a line over 120 characters is cut at its statements, blocks, items and lists."
51
+ },
52
+ {
53
+ "name": "help",
54
+ "synopsis": "help [--all | COMMAND]",
55
+ "summary": "Print the commands by group, or the help of COMMAND."
56
+ },
57
+ {
58
+ "name": "version",
59
+ "synopsis": "version",
60
+ "summary": "Print the identity of the toolchain."
61
+ }
62
+ ]
63
+ },
64
+ {
65
+ "name": "Code and packages",
66
+ "commands": [
67
+ {
68
+ "name": "python",
69
+ "synopsis": "python [OPTION...] (-c CODE | -m MODULE | FILE | -) [ARGUMENT...]",
70
+ "summary": "Run Python as `python3` does, on the VM: CPython's options are taken (most have no effect)."
71
+ },
72
+ {
73
+ "name": "wasm",
74
+ "synopsis": "wasm FILE [ARGUMENT...]",
75
+ "summary": "Build FILE as a wasm64 module and run it in `runtime/wasm.mjs`, which gives it only the console."
76
+ },
77
+ {
78
+ "name": "outline",
79
+ "synopsis": "outline [FLAG...] FILE",
80
+ "summary": "Print the declarations of FILE, one a line: where it is, its signature and effects, and its doc summary under it."
81
+ },
82
+ {
83
+ "name": "context",
84
+ "synopsis": "context FILE WORD...",
85
+ "summary": "Print the declarations that mention a WORD in full, and the first lines of their neighbours."
86
+ },
87
+ {
88
+ "name": "project",
89
+ "synopsis": "project FILE",
90
+ "summary": "Print the canonical source of FILE, with the sugar spelled out."
91
+ },
92
+ {
93
+ "name": "edit",
94
+ "synopsis": "edit FILE",
95
+ "summary": "Apply the JSON edit request on stdin to FILE, if the result checks."
96
+ },
97
+ {
98
+ "name": "lsp",
99
+ "synopsis": "lsp",
100
+ "summary": "Start the language server on stdin and stdout."
101
+ },
102
+ {
103
+ "name": "bind",
104
+ "synopsis": "bind HEADER LIBRARY NAME...",
105
+ "summary": "Print `declare` lines for the C functions NAME of HEADER in LIBRARY."
106
+ },
107
+ {
108
+ "name": "codec",
109
+ "synopsis": "codec FILE TYPE...",
110
+ "summary": "Print JSON functions for the record types TYPE of FILE."
111
+ },
112
+ {
113
+ "name": "i18n",
114
+ "synopsis": "i18n [FLAG...] DIR",
115
+ "summary": "Bring the catalogs `locales/TAG.json` of the `.ghtml` files under DIR up to date: add each message a catalog lacks with an empty translation (shown in English until translated), remove the keys no file shows, and print how much of each catalog is translated."
116
+ },
117
+ {
118
+ "name": "pin",
119
+ "synopsis": "pin DIR",
120
+ "summary": "Write the sha256 pins in `DIR/package.json` again, dependencies first."
121
+ },
122
+ {
123
+ "name": "get",
124
+ "synopsis": "get [FLAG...] DIR [NAME@VERSION... npm:NAME@SPEC...]",
125
+ "summary": "Raise the requirements of the package in DIR, vendor the minimal version selection, run `npm install` (without its scripts) and pin the result."
126
+ },
127
+ {
128
+ "name": "capabilities",
129
+ "synopsis": "capabilities",
130
+ "summary": "Write the operations of this command line and the exact identity of the running toolchain."
131
+ },
132
+ {
133
+ "name": "completion",
134
+ "synopsis": "completion bash",
135
+ "summary": "Print a bash completion script."
136
+ },
137
+ {
138
+ "name": "vscode",
139
+ "synopsis": "vscode",
140
+ "summary": "Install the Gizlang extension of this toolchain into VS Code."
141
+ },
142
+ {
143
+ "name": "licenses",
144
+ "synopsis": "licenses FILE",
145
+ "summary": "Print the third-party notices for the native link inputs of FILE."
146
+ },
147
+ {
148
+ "name": "doctor",
149
+ "synopsis": "doctor",
150
+ "summary": "Check what this machine gives Gizlang: the system and its glibc, the toolchain, `giz` on PATH, free disk space, each GPU with its memory and driver, the models downloaded and the release host."
151
+ }
152
+ ]
153
+ },
154
+ {
155
+ "name": "Deploy and cloud",
156
+ "commands": [
157
+ {
158
+ "name": "deploy",
159
+ "synopsis": "deploy [FLAG...] [FILE...]",
160
+ "summary": "Without `--live`, send FILE, else the folder's `app.giz` (and `app.test.giz`), to GizStack with your terminal token (`GIZ_TOKEN`): it checks and tests the change and publishes it into the running app at NAME.giz.sh, or refuses it and the old version keeps serving."
161
+ },
162
+ {
163
+ "name": "ai",
164
+ "synopsis": "ai [FLAG...] models | pull NAME | chat NAME [PROMPT...] | image PROMPT... | serve NAME... | worker NAME | gate URL CASES DIR | registry",
165
+ "summary": "Run AI models on this computer by name: `models` lists the catalog (the models of every family in ai/models); any Hugging Face repository (`owner/name`) of a family gizai:llm reads is a chat model too."
166
+ },
167
+ {
168
+ "name": "api",
169
+ "synopsis": "api [FLAG...] [NAME [FIELD=VALUE...]]",
170
+ "summary": "Run the GizStack operation NAME with your terminal token (`GIZ_TOKEN`), as the console, MCP and agents do, and print its result as JSON; a refusal prints why and exits 1."
171
+ },
172
+ {
173
+ "name": "ssh-proxy",
174
+ "synopsis": "ssh-proxy COMPUTER [PORT]",
175
+ "summary": "Reach a running Giz Computer of your team through GizStack with your terminal token (`GIZ_TOKEN`): standard input and output are copied through a one-use tunnel to PORT (22 by default) of the Computer, with no public address."
176
+ },
177
+ {
178
+ "name": "publish",
179
+ "synopsis": "publish DIR [NAME@VERSION]",
180
+ "summary": "Publish the package in DIR under the name and version of its manifest."
181
+ },
182
+ {
183
+ "name": "public-key",
184
+ "synopsis": "public-key SEED",
185
+ "summary": "Print the hexadecimal Ed25519 public key of the 32-byte SEED file (`GIZLANG_SIGNING_KEY`), for `GIZLANG_TRUSTED_KEYS`."
186
+ }
187
+ ]
188
+ },
189
+ {
190
+ "name": "Repository",
191
+ "commands": [
192
+ {
193
+ "name": "storage",
194
+ "synopsis": "storage [FLAG...] OPERATION [OPERAND...]",
195
+ "summary": "Manage a storage range: buckets, objects (`ls`, `cp`, `rm`, `stat`, `presign`), keys, usage, a FUSE mount and block volumes."
196
+ },
197
+ {
198
+ "name": "bench",
199
+ "synopsis": "bench [FLAG...] list [SUITE] | run|show|compare|record ID...",
200
+ "summary": "Run the benchmark suites of this toolchain, all or in part."
201
+ },
202
+ {
203
+ "name": "work",
204
+ "synopsis": "work [FLAG...] OPERATION [OPERAND...]",
205
+ "summary": "Manage the work items of the git repository here: `list`, `show`, `open`, `drop`, `claim`, `release`, `claims`, `age`, `burndown`, `check`."
206
+ }
207
+ ]
208
+ }
209
+ ]
210
+ }
package/cli/toolchain.mjs CHANGED
@@ -1,20 +1,19 @@
1
1
  import { spawnSync } from 'node:child_process'
2
- import { existsSync } from 'node:fs'
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
- import { createInterface } from 'node:readline/promises'
6
5
 
7
6
  // The Gizlang toolchain (GizStack: giz init, run, build, test, deploy, … and gizdb) is a native program per OS. This
8
- // package carries none of it: the first command that needs it installs it with gizstack.com's own install script for
9
- // this OS, which names the download size, checks the release signature and every file, and selects the version (a
10
- // project pin or the active one). Later runs execute the installed program at once.
7
+ // package carries none of it: the first command that needs it installs it at once, with no question, by gizstack.com's
8
+ // own install script for this OS, which names the download size first, checks the release signature and every file,
9
+ // and selects the version (a project pin or the active one). Later runs execute the installed program at once.
11
10
  const SITE = 'https://gizstack.com'
12
- // The toolchain's public commands (gizlang tools/cli/commands.giz). Only these, or a program file, install it: an agent
13
- // that asks for help on a command that does not exist must get an error, not a 200 MB download. Once the toolchain is
14
- // installed, every command giz.ai does not know goes to it, and it owns the error.
15
- const COMMANDS = new Set(['init', 'run', 'check', 'build', 'test', 'live', 'doc', 'repl', 'fmt', 'version', 'upgrade',
16
- 'python', 'wasm', 'outline', 'context', 'project', 'edit', 'lsp', 'bind', 'codec', 'i18n', 'pin', 'get', 'capabilities',
17
- 'completion', 'vscode', 'licenses', 'doctor', 'deploy', 'ai', 'api', 'ssh-proxy', 'publish', 'public-key', 'storage', 'bench'])
11
+ // The toolchain's public commands by help group, written from its command table (tools/cli/commands.giz) by
12
+ // tools/cli/npm/commands.giz. Only these, or a program file, install it: an agent that asks for help on a command that
13
+ // does not exist must get an error, not a 200 MB download. Once the toolchain is installed, every command giz.ai does not
14
+ // know goes to it, and it owns the error.
15
+ export const GROUPS = JSON.parse(readFileSync(new URL('./gizlang-commands.json', import.meta.url), 'utf8')).groups
16
+ const COMMANDS = new Set(GROUPS.flatMap((group) => group.commands.map((command) => command.name)))
18
17
  const windows = process.platform === 'win32'
19
18
 
20
19
  export function toolchainRoot(env = process.env) {
@@ -30,26 +29,36 @@ function installed(root, program) {
30
29
  return path.join(root, 'bin', program + (windows ? '.exe' : ''))
31
30
  }
32
31
 
33
- async function confirmed(command) {
34
- // A script or CI has no terminal to ask: it asked for the command, so it gets it.
35
- if (!process.stdin.isTTY || !process.stderr.isTTY) return true
36
- const prompt = createInterface({ input: process.stdin, output: process.stderr })
37
- try {
38
- const answer = await prompt.question(`giz ${command} needs the Gizlang toolchain, which is not installed. Install it from ${SITE} now? [Y/n] `)
39
- return !/^n/i.test(answer.trim())
40
- } finally { prompt.close() }
32
+ /** The installed toolchain's identity (`giz version`), or undefined when it is not installed. It never installs it. */
33
+ export function toolchainVersion() {
34
+ const file = installed(toolchainRoot(), 'giz')
35
+ if (!existsSync(file)) return undefined
36
+ const ran = spawnSync(file, ['version'], { encoding: 'utf8' })
37
+ return ran.status === 0 ? ran.stdout.trim() : undefined
41
38
  }
42
39
 
43
40
  async function install(root) {
44
41
  const response = await fetch(`${SITE}/${windows ? 'install.ps1' : 'install'}`)
45
42
  if (!response.ok) throw new Error(`cannot download the install script: HTTP ${response.status} from ${SITE}`)
46
43
  const script = await response.text()
47
- // The script's progress goes to stderr: stdout stays the command's own.
48
- const ran = windows
49
- ? spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', '-'], { input: script, stdio: ['pipe', process.stderr, 'inherit'], env: { ...process.env, GIZ_INSTALL: root } })
50
- : spawnSync('sh', ['-s', '--', '--no-modify-path'], { input: script, stdio: ['pipe', process.stderr, 'inherit'], env: { ...process.env, GIZ_INSTALL: root } })
51
- if (ran.error) throw new Error(`cannot run the install script: ${ran.error.message}`)
52
- return ran.status === 0
44
+ // The script's progress goes to stderr: stdout stays the command's own. PATH is this package's: the script changes none.
45
+ const env = { ...process.env, GIZ_INSTALL: root, NO_MODIFY_PATH: '1' }
46
+ if (!windows) {
47
+ const ran = spawnSync('sh', ['-s', '--', '--no-modify-path'], { input: script, stdio: ['pipe', process.stderr, 'inherit'], env })
48
+ if (ran.error) throw new Error(`cannot run the install script: ${ran.error.message}`)
49
+ return ran.status === 0
50
+ }
51
+ // PowerShell runs a file whole; from stdin it runs line by line and breaks the script's blocks.
52
+ const folder = mkdtempSync(path.join(os.tmpdir(), 'giz-install-'))
53
+ try {
54
+ const file = path.join(folder, 'install.ps1')
55
+ writeFileSync(file, script)
56
+ const ran = spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', file], { stdio: ['ignore', process.stderr, 'inherit'], env })
57
+ if (ran.error) throw new Error(`cannot run the install script: ${ran.error.message}`)
58
+ return ran.status === 0
59
+ } finally {
60
+ rmSync(folder, { recursive: true, force: true })
61
+ }
53
62
  }
54
63
 
55
64
  /** Runs PROGRAM (giz or gizdb) of the toolchain with ARGS, installing the toolchain first when it is missing. */
@@ -57,7 +66,6 @@ export async function runToolchain(program, args) {
57
66
  const root = toolchainRoot()
58
67
  const file = installed(root, program)
59
68
  if (!existsSync(file)) {
60
- if (!await confirmed(args[0] || program)) { process.exitCode = 1; return }
61
69
  if (!await install(root) || !existsSync(file)) {
62
70
  process.stderr.write(`The Gizlang toolchain is not installed, so giz ${args[0] || program} cannot run here. Supported platforms: ${SITE}/download\n`)
63
71
  process.exitCode = 1
package/cli/update.mjs CHANGED
@@ -17,10 +17,10 @@ export function installSource(root = packageRoot) {
17
17
  const nodeModules = parts.lastIndexOf('node_modules')
18
18
  if (nodeModules < 0) return { type: 'source' }
19
19
  const normalized = resolved.toLowerCase()
20
- if (normalized.includes(`${path.sep}.pnpm${path.sep}`) || /[\\/]pnpm[\\/]global[\\/]/.test(normalized)) return { type: 'pnpm', executable: 'pnpm', args: ['add', '-g', '@gizai/cli'], command: 'pnpm add -g @gizai/cli' }
21
- if (normalized.includes(`${path.sep}.bun${path.sep}`)) return { type: 'bun', executable: 'bun', args: ['add', '-g', '@gizai/cli'], command: 'bun add -g @gizai/cli' }
22
- if (normalized.includes(`${path.sep}.yarn${path.sep}`) || normalized.includes(`${path.sep}yarn${path.sep}global${path.sep}`)) return { type: 'yarn', executable: 'yarn', args: ['global', 'add', '@gizai/cli'], command: 'yarn global add @gizai/cli' }
23
- return { type: 'npm', executable: 'npm', args: ['install', '-g', '@gizai/cli'], command: UPDATE_COMMAND }
20
+ if (normalized.includes(`${path.sep}.pnpm${path.sep}`) || /[\\/]pnpm[\\/]global[\\/]/.test(normalized)) return { type: 'pnpm', executable: 'pnpm', args: ['add', '-g', '@gizai/cli'], command: 'pnpm add -g @gizai/cli', remove: 'pnpm remove -g @gizai/cli' }
21
+ if (normalized.includes(`${path.sep}.bun${path.sep}`)) return { type: 'bun', executable: 'bun', args: ['add', '-g', '@gizai/cli'], command: 'bun add -g @gizai/cli', remove: 'bun remove -g @gizai/cli' }
22
+ if (normalized.includes(`${path.sep}.yarn${path.sep}`) || normalized.includes(`${path.sep}yarn${path.sep}global${path.sep}`)) return { type: 'yarn', executable: 'yarn', args: ['global', 'add', '@gizai/cli'], command: 'yarn global add @gizai/cli', remove: 'yarn global remove @gizai/cli' }
23
+ return { type: 'npm', executable: 'npm', args: ['install', '-g', '@gizai/cli'], command: UPDATE_COMMAND, remove: 'npm uninstall -g @gizai/cli' }
24
24
  }
25
25
 
26
26
  export function stableVersion(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gizai/cli",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "description": "The official command-line interface for giz.ai: run AI models, Drive, Computers and Apps, connect Claude Code, Codex and other agents over MCP, and run Gizlang (giz init, run, deploy), installed on first use.",
5
5
  "keywords": [
6
6
  "giz",
@@ -25,7 +25,7 @@
25
25
  "name": "Giz Inc.",
26
26
  "url": "https://www.giz.ai"
27
27
  },
28
- "license": "UNLICENSED",
28
+ "license": "MIT",
29
29
  "type": "module",
30
30
  "bin": {
31
31
  "giz": "cli/giz.mjs",
@@ -34,6 +34,7 @@
34
34
  "files": [
35
35
  "cli",
36
36
  "skills",
37
+ "LICENSE",
37
38
  "plugin.json",
38
39
  "mcp.json",
39
40
  "README.md"
@@ -47,8 +48,8 @@
47
48
  },
48
49
  "repository": {
49
50
  "type": "git",
50
- "url": "git+https://github.com/gizai/giz.git",
51
- "directory": "apps/giz/external"
51
+ "url": "git+https://github.com/gizai/gizlang.git",
52
+ "directory": "tools/cli/npm"
52
53
  },
53
54
  "publishConfig": {
54
55
  "access": "public"
package/plugin.json CHANGED
@@ -5,6 +5,6 @@
5
5
  "description": "Run Giz models, media generation, Drive, tables, Apps and Computers from any agent: the giz skill plus the Giz MCP server.",
6
6
  "author": { "name": "Giz", "url": "https://www.giz.ai" },
7
7
  "homepage": "https://www.giz.ai/mcp/",
8
- "license": "UNLICENSED",
8
+ "license": "MIT",
9
9
  "keywords": ["giz", "mcp", "media", "drive", "computer"]
10
10
  }
@@ -69,9 +69,7 @@ App changes pass the `revision` read from `giz app list` explicitly.
69
69
  The `--input` of `create` and `save` carries `id` and `expectedRevision` (0 for a new App).
70
70
  The CLI never re-reads the list to substitute a revision, so the server rejects the call if another change landed after your review.
71
71
 
72
- The table below is generated from the CLI dispatcher and the tool descriptors. To refresh it, run:
73
- `GIZ_UPDATE_CLI_SURFACE=1 pnpm --filter giz-app-giz test --runInBand --runTestsByPath server/external/cli-surface.spec.ts`.
74
- A normal CI run does not write the document; it fails on any difference in the table.
72
+ The table below is generated from the CLI dispatcher and the tool descriptors of the giz.ai server, which checks it.
75
73
 
76
74
  <!-- cli-surface:start -->
77
75
  | Command | Tool ID | MCP tool | Required input | Scope | Agent discovery |
@@ -1,46 +0,0 @@
1
- // Runs ONLY inside the user's Computer. stdout is the authenticated gateway wire,
2
- // never credentials or provider error bodies. No listener or separate credential store.
3
- import { readFile } from 'node:fs/promises'
4
- import { homedir } from 'node:os'
5
- import { join } from 'node:path'
6
- import { execFileSync } from 'node:child_process'
7
-
8
- const emit = value => process.stdout.write(JSON.stringify(value) + '\n')
9
- try {
10
- const auth = JSON.parse(await readFile(join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'auth.json'), 'utf8'))
11
- const token = auth.tokens?.access_token
12
- const account = auth.tokens?.account_id
13
- if (auth.OPENAI_API_KEY || !token || !account) {
14
- emit({ status: 401 }); process.exitCode = 1
15
- } else {
16
- const headers = { authorization: `Bearer ${token}`, 'chatgpt-account-id': account, 'content-type': 'application/json', originator: 'codex_cli_rs' }
17
- const mode = process.argv[2]
18
- if (mode === 'models') {
19
- const version = execFileSync('codex', ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().split(' ').at(-1)
20
- const response = await fetch(`https://chatgpt.com/backend-api/codex/models?client_version=${encodeURIComponent(version)}`, { headers, signal: AbortSignal.timeout(30000) })
21
- if (!response.ok) emit({ status: response.status })
22
- else {
23
- const payload = await response.json()
24
- emit({ status: 200, models: (payload.models || []).filter(model => model.visibility !== 'hide').map(model => ({ id: model.slug, label: model.display_name || model.slug })).filter(model => typeof model.id === 'string' && model.id.length <= 256) })
25
- }
26
- } else if (mode === 'responses') {
27
- let input = ''
28
- for await (const chunk of process.stdin) input += chunk
29
- const body = JSON.parse(input)
30
- body.store = false
31
- body.stream = true
32
- body.instructions ??= ''
33
- delete body.max_output_tokens
34
- delete body.temperature
35
- delete body.top_p
36
- const response = await fetch('https://chatgpt.com/backend-api/codex/responses', { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(300000) })
37
- emit({ status: response.status })
38
- if (response.ok) {
39
- for await (const chunk of response.body) emit({ chunk: Buffer.from(chunk).toString('base64') })
40
- emit({ done: true })
41
- }
42
- } else emit({ status: 400 })
43
- }
44
- } catch {
45
- emit({ status: 503 }); process.exitCode = 1
46
- }
@@ -1,81 +0,0 @@
1
- // The Agent runtime's stdio MCP client. It runs in the person's Computer as
2
- // `node --input-type=module -e <this source> <request JSON> <server argv...>`: it starts the server, initializes it,
3
- // sends one request (tools/list follows every cursor) and prints the result as one JSON line. The server's stderr is
4
- // shown only when the call fails. It replaced `npx @modelcontextprotocol/inspector@0.4.1 --cli`, a version without a
5
- // CLI mode that opened its web UI and hung every plugin call until it was canceled (10/6).
6
- import { spawn } from 'node:child_process';
7
-
8
- const [requestJson, command, ...args] = process.argv.slice(1);
9
- const request = JSON.parse(requestJson);
10
- const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
11
- let stderr = '';
12
- child.stderr.on('data', (chunk) => { stderr = (stderr + chunk).slice(-8000); });
13
-
14
- function fail(message) {
15
- process.stderr.write(`${message}\n${stderr}`);
16
- child.kill('SIGKILL');
17
- process.exit(1);
18
- }
19
- child.on('error', (error) => fail(`MCP server could not start: ${error.message}`));
20
- child.on('exit', (code, signal) => fail(`MCP server exited (${signal || code}) before it answered.`));
21
-
22
- const send = (message) => child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', ...message })}\n`);
23
- const pending = new Map();
24
- let nextId = 1;
25
- let buffer = '';
26
- child.stdout.setEncoding('utf8');
27
- child.stdout.on('data', (chunk) => {
28
- buffer += chunk;
29
- for (let newline = buffer.indexOf('\n'); newline >= 0; newline = buffer.indexOf('\n')) {
30
- const line = buffer.slice(0, newline).trim();
31
- buffer = buffer.slice(newline + 1);
32
- let message;
33
- // Like the MCP SDK, a line that is not JSON-RPC (a stray log) is skipped rather than ending the call.
34
- try { message = JSON.parse(line); } catch { continue; }
35
- if (message?.method && message.id !== undefined) {
36
- send(message.method === 'ping' ? { id: message.id, result: {} }
37
- : { id: message.id, error: { code: -32601, message: `${message.method} is not supported by this client.` } });
38
- continue;
39
- }
40
- const waiter = pending.get(message?.id);
41
- if (!waiter) continue;
42
- pending.delete(message.id);
43
- if (message.error) fail(`MCP ${waiter.method} failed: ${message.error.message || JSON.stringify(message.error)}`);
44
- else waiter.resolve(message.result);
45
- }
46
- });
47
-
48
- function call(method, params) {
49
- return new Promise((resolve) => {
50
- const id = nextId++;
51
- pending.set(id, { method, resolve });
52
- send({ id, method, ...(params === undefined ? {} : { params }) });
53
- });
54
- }
55
-
56
- await call('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'giz-agent', version: '1' } });
57
- send({ method: 'notifications/initialized' });
58
- let result;
59
- if (request.method === 'tools/list') {
60
- const tools = [];
61
- const cursors = new Set();
62
- let cursor;
63
- for (;;) {
64
- const page = await call('tools/list', cursor === undefined ? undefined : { cursor });
65
- if (!Array.isArray(page?.tools)) fail('MCP server returned an invalid tool catalog.');
66
- tools.push(...page.tools);
67
- if (page?.nextCursor == null) break;
68
- if (cursors.has(page.nextCursor) || cursors.size >= 32) fail('MCP server did not finish its tool catalog.');
69
- cursors.add(page.nextCursor);
70
- cursor = page.nextCursor;
71
- }
72
- result = { tools };
73
- } else if (request.method === 'tools/call') {
74
- result = await call('tools/call', { name: request.name, arguments: request.arguments ?? {} });
75
- } else {
76
- fail(`Unsupported MCP request ${request.method}.`);
77
- }
78
- child.removeAllListeners('exit');
79
- process.stdout.write(`${JSON.stringify(result)}\n`);
80
- child.kill();
81
- process.exit(0);
@@ -1,321 +0,0 @@
1
- // media.capture inside the person's Computer: records a browser scenario at 2x device pixels, logs every pointer target,
2
- // typing span and wait, shortens long waits, and writes an editable HyperFrames take (camera that follows the action, a
3
- // redrawn cursor, click and typing feedback, speed chips). The request JSON file (its path is the one argument)
4
- // is deleted once read: its sign-in steps carry the person's secrets. Result JSON on stdout.
5
- // Runs with giz-media-node (video-tools runtime): playwright-core and HYPERFRAMES_BROWSER_PATH come from that runtime.
6
- import { createRequire } from 'node:module';
7
- import { execFileSync } from 'node:child_process';
8
- import fs from 'node:fs';
9
- import path from 'node:path';
10
-
11
- const request = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
12
- fs.rmSync(process.argv[1], { force: true });
13
- const { chromium } = createRequire(path.join(process.cwd(), 'noop.js'))('playwright-core');
14
- const out = request.dir;
15
- const [VW, VH] = request.viewport;
16
- const [CW, CH] = request.canvas;
17
- const FPS = 30;
18
- const SHORTEN_AFTER = 2.5; // A wait longer than this plays at a labelled speed instead of real time.
19
- const SHORTENED = 1.2;
20
- fs.mkdirSync(path.join(out, 'frames'), { recursive: true });
21
-
22
- const browser = await chromium.launch({ executablePath: process.env.HYPERFRAMES_BROWSER_PATH, args: ['--force-device-scale-factor=2'] });
23
- const context = await browser.newContext({ viewport: { width: VW, height: VH }, deviceScaleFactor: 2, locale: request.locale,
24
- timezoneId: request.timezone });
25
- const page = await context.newPage();
26
- const events = [];
27
- let t0 = 0;
28
- const now = () => (Date.now() - t0) / 1000;
29
- const round = (value) => Math.round(value * 1000) / 1000;
30
- const log = (event) => { if (t0) events.push({ t: round(now()), ...event }); };
31
-
32
- function locate(target) {
33
- const text = String(target || '');
34
- if (text.startsWith('css:')) return page.locator(text.slice(4)).first();
35
- if (text.startsWith('label:')) return page.getByLabel(text.slice(6)).locator('visible=true').first();
36
- if (text.startsWith('placeholder:')) return page.getByPlaceholder(text.slice(12)).locator('visible=true').first();
37
- if (text.startsWith('role:')) {
38
- const [, role, ...name] = text.split(':');
39
- return page.getByRole(role, { name: name.join(':') || undefined }).locator('visible=true').first();
40
- }
41
- // Visible text: an exact match first, so "저장" does not pick "저장된 보기".
42
- const exact = page.getByText(text, { exact: true }).locator('visible=true');
43
- return exact.or(page.getByText(text).locator('visible=true')).first();
44
- }
45
-
46
- async function box(locator) {
47
- const b = await locator.boundingBox().catch(() => null);
48
- return b && { x: round(b.x), y: round(b.y), w: round(b.width), h: round(b.height) };
49
- }
50
-
51
- // A real pointer path, so hover states appear the way a person causes them.
52
- async function point(step) {
53
- const locator = locate(step.target);
54
- await locator.waitFor({ state: 'visible', timeout: step.timeoutMs ?? 15000 });
55
- await locator.scrollIntoViewIfNeeded().catch(() => {});
56
- const b = await box(locator);
57
- if (!b) throw new Error(`${step.target} has no visible box`);
58
- const x = b.x + b.w / 2, y = b.y + b.h / 2;
59
- await page.mouse.move(x, y, { steps: 18 });
60
- return { locator, b, x, y };
61
- }
62
-
63
- const label = (step) => step.label || (step.secret ? '••••' : step.target || step.value || step.action);
64
-
65
- async function run(step) {
66
- const timeout = step.timeoutMs ?? 15000;
67
- switch (step.action) {
68
- case 'open': await page.goto(new URL(step.value, page.url() === 'about:blank' ? request.url : page.url()).href, { waitUntil: 'domcontentloaded' }); log({ action: 'open', label: label(step) }); break;
69
- case 'click': case 'doubleClick': case 'rightClick': case 'hover': {
70
- const { b, x, y } = await point(step);
71
- log({ action: step.action, label: label(step), box: b, x, y });
72
- if (step.action === 'click') await page.mouse.click(x, y);
73
- if (step.action === 'doubleClick') await page.mouse.dblclick(x, y);
74
- if (step.action === 'rightClick') await page.mouse.click(x, y, { button: 'right' });
75
- break;
76
- }
77
- case 'type': case 'fill': {
78
- const { locator, b, x, y } = await point(step);
79
- log({ action: 'click', label: label(step), box: b, x, y });
80
- await page.mouse.click(x, y);
81
- if (step.action === 'fill') await locator.fill('');
82
- const from = now();
83
- await locator.pressSequentially(String(step.value ?? ''), { delay: 45 });
84
- log({ action: 'typing', label: label(step), box: await box(locator), from: round(from) });
85
- break;
86
- }
87
- case 'press': await page.keyboard.press(step.value); log({ action: 'press', label: step.value }); break;
88
- case 'upload': {
89
- const files = String(step.value || '').split('\n').filter(Boolean).map((file) => path.resolve('/home/user', file));
90
- await locate(step.target).setInputFiles(files, { timeout });
91
- log({ action: 'upload', label: step.label || files.map((file) => path.basename(file)).join(', ') });
92
- break;
93
- }
94
- case 'waitFor': {
95
- const from = now();
96
- await locate(step.target).waitFor({ state: 'visible', timeout: step.timeoutMs ?? 180000 });
97
- log({ action: 'wait', label: label(step), from: round(from) });
98
- break;
99
- }
100
- case 'scroll': {
101
- const locator = locate(step.target);
102
- await locator.waitFor({ state: 'attached', timeout });
103
- await locator.evaluate((element) => element.scrollIntoView({ block: 'center', behavior: 'smooth' }));
104
- await page.waitForTimeout(900);
105
- log({ action: 'scroll', label: label(step), box: await box(locator) });
106
- break;
107
- }
108
- case 'focus': {
109
- const locator = locate(step.target);
110
- await locator.waitFor({ state: 'visible', timeout });
111
- log({ action: 'focus', label: label(step), box: await box(locator) });
112
- await page.waitForTimeout(step.ms ?? 1500);
113
- break;
114
- }
115
- case 'pause': await page.waitForTimeout(step.ms ?? 1000); break;
116
- case 'mark': log({ action: 'mark', label: label(step) }); break;
117
- default: throw new Error(`Unknown capture action: ${step.action}`);
118
- }
119
- }
120
-
121
- const frames = [];
122
- let cdp;
123
- let failure;
124
- let failedStep;
125
- try {
126
- await page.goto(request.url, { waitUntil: 'domcontentloaded' });
127
- await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
128
- for (const [index, step] of request.setup.entries()) {
129
- failedStep = { phase: 'setup', index };
130
- await run(step);
131
- }
132
- // Only the scenario is recorded; signing in and preparing data stay off camera.
133
- cdp = await context.newCDPSession(page);
134
- cdp.on('Page.screencastFrame', async (frame) => {
135
- const file = `frames/${String(frames.length).padStart(6, '0')}.jpg`;
136
- fs.writeFileSync(path.join(out, file), Buffer.from(frame.data, 'base64'));
137
- frames.push([frame.metadata.timestamp, file]);
138
- await cdp.send('Page.screencastFrameAck', { sessionId: frame.sessionId }).catch(() => {});
139
- });
140
- t0 = Date.now();
141
- await cdp.send('Page.startScreencast', { format: 'jpeg', quality: 92, maxWidth: VW * 2, maxHeight: VH * 2 });
142
- await page.waitForTimeout(800);
143
- for (const [index, step] of request.steps.entries()) {
144
- failedStep = { phase: 'steps', index };
145
- await run(step);
146
- }
147
- await page.waitForTimeout(1200);
148
- failedStep = undefined;
149
- } catch (error) {
150
- failure = String(error?.message || error).split('\n')[0];
151
- await page.screenshot({ path: path.join(out, 'failed.png') }).catch(() => {});
152
- } finally {
153
- await cdp?.send('Page.stopScreencast').catch(() => {});
154
- }
155
- const end = now();
156
- await context.close();
157
- await browser.close();
158
- if (failure) {
159
- fs.rmSync(path.join(out, 'frames'), { recursive: true, force: true });
160
- console.log(JSON.stringify({ failed: true, failure, step: failedStep, screenshot: 'failed.png' }));
161
- process.exit(0);
162
- }
163
-
164
- // Long waits are shortened: a piecewise-linear clock maps recorded time to take time, and the frames, events and speed
165
- // chips all read that one map, so the cursor and camera stay on the action.
166
- const spans = events.filter((event) => event.action === 'wait' && event.t - event.from > SHORTEN_AFTER)
167
- .map((event) => ({ from: event.from, to: event.t, rate: Math.min(16, (event.t - event.from) / SHORTENED) }));
168
- const clock = (t) => {
169
- let shift = 0;
170
- for (const span of spans) {
171
- if (t <= span.from) break;
172
- const inside = Math.min(t, span.to) - span.from;
173
- shift += inside - inside / span.rate;
174
- }
175
- return round(t - shift);
176
- };
177
- const duration = clock(end);
178
- const start = frames[0][0];
179
- const list = frames.map(([stamp, file], index) => {
180
- const at = clock(stamp - start);
181
- const next = index + 1 < frames.length ? clock(frames[index + 1][0] - start) : duration;
182
- return `file '${file}'\nduration ${Math.max(0.001, next - at).toFixed(3)}`;
183
- });
184
- fs.writeFileSync(path.join(out, 'frames.txt'), `ffconcat version 1.0\n${list.join('\n')}\nfile '${frames.at(-1)[1]}'\n`);
185
- // A keyframe every second: HyperFrames seeks the recording frame by frame and freezes on sparse keyframes.
186
- // Threads and lookahead are fixed: ffmpeg sizes them by the cores it sees, and a 2 GB Computer that saw the host's cores
187
- // was OOM-killed at 1.4 GB on a 2880x1620 take (prod 10/8); fixed, it stays near 0.55 GB on any core count.
188
- try {
189
- execFileSync('ffmpeg', ['-v', 'error', '-y', '-threads', '2', '-filter_threads', '1', '-f', 'concat', '-safe', '0', '-i', path.join(out, 'frames.txt'),
190
- '-vf', `fps=${FPS},scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p`, '-c:v', 'libx264', '-crf', '16', '-preset', 'medium', '-g', String(FPS),
191
- '-threads', '2', '-rc-lookahead', '10', '-movflags', '+faststart', path.join(out, 'screen.mp4')]);
192
- } catch (error) {
193
- // The job reports an out-of-memory kill as such (137), not as a stack the Agent would answer by lowering quality.
194
- if (error.signal === 'SIGKILL') { console.error('The screen recording encoder ran out of Computer memory.'); process.exit(137); }
195
- throw error;
196
- }
197
- fs.rmSync(path.join(out, 'frames'), { recursive: true, force: true });
198
- fs.rmSync(path.join(out, 'frames.txt'), { force: true });
199
- const timeline = events.map((event) => ({ ...event, t: clock(event.t), ...(event.from !== undefined ? { from: clock(event.from) } : {}) }));
200
- const shortened = spans.map((span) => ({ from: clock(span.from), to: clock(span.to), rate: Math.round(span.rate * 10) / 10 }));
201
- fs.writeFileSync(path.join(out, 'events.json'), JSON.stringify({ viewport: [VW, VH], scale: 2, duration, shortened, events: timeline }, null, 1));
202
- fs.writeFileSync(path.join(out, 'take.html'), composition(timeline, shortened));
203
- console.log(JSON.stringify({ duration, shortened, events: timeline.map(({ t, action, label: name, from }) => ({ t, action, label: name, ...(from !== undefined ? { from } : {}) })) }));
204
-
205
- // The take composition: the screen fills the canvas, the camera frames each cluster of actions and returns to the whole
206
- // screen between them, and the cursor keeps its size while the camera zooms because it lives inside the camera.
207
- function composition(timeline, shortened) {
208
- const scale = Math.min(CW / VW, CH / VH);
209
- const SW = VW * scale, SH = VH * scale, SX = (CW - SW) / 2, SY = (CH - SH) / 2;
210
- const maxZoom = request.camera === false ? 1 : request.maxZoom;
211
- const id = request.name;
212
- const tl = [];
213
- const html = [];
214
- const frame = (b) => {
215
- if (!b || maxZoom <= 1) return { s: 1, x: 0, y: 0 };
216
- const pad = 1.3;
217
- const s = Math.max(1, Math.min(maxZoom, SW / (b.w * scale * pad), SH / (b.h * scale * pad)));
218
- const cx = (b.x + b.w / 2) * scale, cy = (b.y + b.h / 2) * scale;
219
- const x = Math.min(0, Math.max(SW - SW * s, SW / 2 - cx * s));
220
- const y = Math.min(0, Math.max(SH - SH * s, SH / 2 - cy * s));
221
- return { s: round(s), x: Math.round(x), y: Math.round(y) };
222
- };
223
- const union = (a, b) => {
224
- const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
225
- return { x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y };
226
- };
227
- // Actions close in time and space share one framing; a page change or a shortened wait ends it.
228
- const clusters = [];
229
- for (const event of timeline) {
230
- const last = clusters.at(-1);
231
- if (event.action === 'open' || event.action === 'wait') { if (last) last.closed = true; continue; }
232
- if (!event.box) continue;
233
- const begins = event.from ?? event.t;
234
- const ends = event.action === 'focus' ? event.t + 1.5 : event.t;
235
- if (last && !last.closed && begins - last.end < 3 && frame(union(last.box, event.box)).s >= 1.2) {
236
- last.box = union(last.box, event.box);
237
- last.end = Math.max(last.end, ends);
238
- } else clusters.push({ start: begins, end: ends, box: event.box });
239
- }
240
- // A framing too wide to zoom is a full-screen shot, so the camera always lands on what the next action shows.
241
- const moves = [];
242
- const wide = { s: 1, x: 0, y: 0 };
243
- let camera = wide, free = 0;
244
- for (const [index, cluster] of clusters.entries()) {
245
- const framed = frame(cluster.box);
246
- const target = framed.s < 1.15 ? wide : framed;
247
- if (target.s !== camera.s || target.x !== camera.x || target.y !== camera.y) {
248
- const at = Math.max(free, cluster.start - 0.8);
249
- moves.push({ at: round(at), d: round(Math.min(0.8, Math.max(0.3, cluster.start - at))), ...target });
250
- camera = target;
251
- }
252
- const hold = cluster.end + 1.4;
253
- free = hold;
254
- const next = clusters[index + 1];
255
- if (camera !== wide && (!next || next.start - 0.8 - hold > 1.6)) {
256
- moves.push({ at: round(Math.min(hold, duration - 0.9)), d: 0.9, ...wide });
257
- camera = wide;
258
- free = hold + 0.9;
259
- }
260
- }
261
- for (const move of moves) {
262
- tl.push(`tl.to("#${id}-cam",{scale:${move.s},x:${move.x},y:${move.y},duration:${move.d},ease:"power2.inOut"},${move.at});`);
263
- tl.push(`tl.to("#${id}-cursor .scale",{scale:${round(1 / move.s)},duration:${move.d},ease:"power2.inOut"},${move.at});`);
264
- }
265
- const pointers = timeline.filter((event) => ['click', 'doubleClick', 'rightClick', 'hover'].includes(event.action) && event.x !== undefined);
266
- if (pointers.length) {
267
- const first = pointers[0];
268
- tl.push(`tl.set("#${id}-cursor",{x:${Math.round(Math.min(VW - 40, first.x + 240) * scale)},y:${Math.round(Math.min(VH - 40, first.y + 160) * scale)}},0);`);
269
- let previousAt = 0;
270
- for (const event of pointers) {
271
- const move = round(Math.max(0.25, Math.min(0.7, event.t - previousAt - 0.1)));
272
- tl.push(`tl.to("#${id}-cursor",{x:${Math.round(event.x * scale)},y:${Math.round(event.y * scale)},duration:${move},ease:"power2.inOut"},${round(Math.max(0, event.t - move))});`);
273
- if (event.action !== 'hover') {
274
- tl.push(`tl.to("#${id}-cursor svg",{scale:0.82,duration:0.08,yoyo:true,repeat:1,transformOrigin:"6px 3px"},${event.t});`);
275
- tl.push(`tl.fromTo("#${id}-cursor .ring",{scale:0.2,opacity:0.9},{scale:1.6,opacity:0,duration:0.5,ease:"power2.out",immediateRender:false},${event.t});`);
276
- }
277
- previousAt = event.t;
278
- }
279
- }
280
- for (const [index, event] of timeline.filter((item) => item.action === 'typing' && item.box).entries()) {
281
- const b = event.box;
282
- html.push(`<div class="typing" id="${id}-type${index}" style="left:${Math.round(b.x * scale - 5)}px;top:${Math.round(b.y * scale - 5)}px;width:${Math.round(b.w * scale + 10)}px;height:${Math.round(b.h * scale + 10)}px"></div>`);
283
- tl.push(`tl.fromTo("#${id}-type${index}",{opacity:0},{opacity:1,duration:0.2},${event.from});`);
284
- tl.push(`tl.to("#${id}-type${index}",{opacity:0,duration:0.3},${round(event.t + 0.6)});`);
285
- }
286
- const chips = shortened.map((span, index) => {
287
- tl.push(`tl.fromTo("#${id}-speed${index}",{opacity:0,scale:0.9},{opacity:1,scale:1,duration:0.2},${span.from});`);
288
- tl.push(`tl.to("#${id}-speed${index}",{opacity:0,duration:0.2},${round(Math.max(span.from + 0.2, span.to - 0.2))});`);
289
- return `<div class="speed" id="${id}-speed${index}">▶▶ ${span.rate}×</div>`;
290
- });
291
- return `<!doctype html>
292
- <html><head><meta charset="UTF-8">
293
- <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
294
- <style>
295
- #${id}{position:relative;width:${CW}px;height:${CH}px;overflow:hidden;background:#0b0c10;}
296
- #${id} .screen{position:absolute;left:${SX}px;top:${SY}px;width:${SW}px;height:${SH}px;overflow:hidden;}
297
- #${id}-cam{position:absolute;inset:0;transform-origin:0 0;}
298
- #${id}-cam video{position:absolute;inset:0;width:100%;height:100%;object-fit:fill;}
299
- #${id} .typing{position:absolute;border-radius:10px;border:3px solid #6463cc;box-shadow:0 0 0 6px rgba(100,99,204,.18);opacity:0;}
300
- #${id}-cursor{position:absolute;left:0;top:0;width:0;height:0;z-index:5;}
301
- #${id}-cursor .scale{position:absolute;left:-6px;top:-3px;transform-origin:6px 3px;filter:drop-shadow(0 3px 6px rgba(0,0,0,.35));}
302
- #${id}-cursor .ring{position:absolute;left:-26px;top:-29px;width:64px;height:64px;border-radius:50%;border:3px solid #6463cc;opacity:0;}
303
- #${id} .speed{position:absolute;right:${Math.round(SX + 28)}px;top:${Math.round(SY + 24)}px;z-index:6;background:rgba(12,14,30,.86);color:#fff;
304
- font:600 24px/1 system-ui,sans-serif;padding:12px 20px;border-radius:999px;opacity:0;}
305
- </style></head>
306
- <body><div id="${id}" data-composition-id="${id}" data-start="0" data-width="${CW}" data-height="${CH}" data-duration="${duration}">
307
- <div class="screen"><div id="${id}-cam">
308
- <video id="${id}-screen" src="screen.mp4" muted playsinline data-start="0" data-duration="${duration}"></video>
309
- ${html.join('\n')}
310
- <div id="${id}-cursor"${pointers.length ? '' : ' style="display:none"'}><div class="scale"><div class="ring"></div><svg viewBox="0 0 32 32" width="${Math.round(40 * scale / 1.2)}" height="${Math.round(40 * scale / 1.2)}"><path d="M6 3 L6 25 L11.5 19.8 L15.4 28.6 L19.2 27 L15.4 18.4 L23 18.2 Z" fill="#111" stroke="#fff" stroke-width="2" stroke-linejoin="round"/></svg></div></div>
311
- </div></div>
312
- ${chips.join('\n')}
313
- </div>
314
- <script>
315
- const tl = gsap.timeline({ paused: true });
316
- ${tl.join('\n')}
317
- window.__timelines = window.__timelines || {};
318
- window.__timelines["${id}"] = tl;
319
- </script></body></html>
320
- `;
321
- }