@gizai/cli 1.0.1 → 1.2.1

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/cli/giz.mjs CHANGED
@@ -1,22 +1,27 @@
1
1
  #!/usr/bin/env node
2
+ import { computerIdentity, managedRevision } from './computer-identity.mjs'
2
3
  import { createHash, randomBytes } from 'node:crypto'
3
4
  import { openAsBlob } from 'node:fs'
4
- import { chmod, mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
5
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
5
6
  import { createServer } from 'node:http'
7
+ import { once } from 'node:events'
6
8
  import { createRequire } from 'node:module'
7
9
  import os from 'node:os'
8
10
  import path from 'node:path'
9
11
  import { spawn, spawnSync } from 'node:child_process'
10
12
  import { fileURLToPath } from 'node:url'
13
+ import { lock } from 'proper-lockfile'
14
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
15
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
16
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
11
17
 
12
18
  const { version: CLI_VERSION } = createRequire(import.meta.url)('../package.json')
13
19
  const DEFAULT_URL = 'https://www.giz.ai'
14
- const CALLBACK_URL = 'http://127.0.0.1:53682/callback'
15
20
  const CLIENT_ID = 'giz-cli'
16
- const SCOPES = ['giz.read', 'giz.write', 'giz.execute', 'giz.destructive', 'offline_access']
17
- const BOOLEAN_OPTIONS = new Set(['help', 'json', 'no-open', 'version'])
18
- const configPath = process.env.GIZ_CONFIG ||
19
- path.join(os.homedir(), '.config', 'giz', 'credentials.json')
21
+ const SCOPES = ['giz.read', 'giz.inference', 'giz.write', 'giz.execute', 'giz.destructive', 'offline_access']
22
+ const BOOLEAN_OPTIONS = new Set(['help', 'json', 'no-open', 'version', 'device'])
23
+ const configPath = path.resolve(process.env.GIZ_CONFIG ||
24
+ path.join(os.homedir(), '.config', 'giz', 'credentials.json'))
20
25
 
21
26
  class CliError extends Error {
22
27
  constructor(message, hint, status) {
@@ -65,14 +70,27 @@ async function readConfig() {
65
70
  }
66
71
  }
67
72
 
68
- async function saveConfig(config) {
69
- await mkdir(path.dirname(configPath), { recursive: true })
70
- await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
71
- await chmod(configPath, 0o600)
73
+ async function updateConfig(update) {
74
+ await mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 })
75
+ const release = await lock(configPath, { realpath: false, retries: { retries: 120, minTimeout: 100, maxTimeout: 500 } })
76
+ const temporary = `${configPath}.${randomBytes(8).toString('hex')}`
77
+ try {
78
+ const config = await update(await readConfig())
79
+ if (config) {
80
+ await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600, flag: 'wx' })
81
+ await rename(temporary, configPath)
82
+ } else {
83
+ await unlink(configPath).catch((error) => { if (error.code !== 'ENOENT') throw error })
84
+ }
85
+ return config
86
+ } finally {
87
+ try { await unlink(temporary).catch((error) => { if (error.code !== 'ENOENT') throw error }) }
88
+ finally { await release() }
89
+ }
72
90
  }
73
91
 
74
92
  async function discovery(baseUrl) {
75
- const response = await fetch(new URL('/.well-known/oauth-authorization-server', baseUrl))
93
+ const response = await fetch(new URL('/.well-known/oauth-authorization-server', baseUrl), { signal: AbortSignal.timeout(30_000) })
76
94
  if (!response.ok) {
77
95
  throw new CliError(
78
96
  `OAuth discovery failed: HTTP ${response.status}.`,
@@ -82,143 +100,158 @@ async function discovery(baseUrl) {
82
100
  return await response.json()
83
101
  }
84
102
 
85
- async function refresh(config) {
86
- if (!config?.refresh_token) {
87
- throw new CliError('Not authenticated.', 'Run: giz auth login')
88
- }
89
- const metadata = await discovery(config.baseUrl)
103
+ async function exchangeToken(baseUrl, metadata, grant, config = {}) {
90
104
  const body = new URLSearchParams({
91
- grant_type: 'refresh_token',
92
105
  client_id: CLIENT_ID,
93
- refresh_token: config.refresh_token,
94
- resource: new URL('/api/mcp', config.baseUrl).href
106
+ resource: new URL('/api/mcp', baseUrl).href,
107
+ ...grant
95
108
  })
96
109
  const response = await fetch(metadata.token_endpoint, {
97
110
  method: 'POST',
98
111
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
99
- body
112
+ body,
113
+ signal: AbortSignal.timeout(30_000)
100
114
  })
101
115
  const payload = await response.json()
102
116
  if (!response.ok) {
103
- throw new CliError(
104
- payload.error_description || payload.error || 'Giz session could not be refreshed.',
105
- 'Run: giz auth login'
106
- )
117
+ const error = new CliError(payload.error_description || payload.error || 'OAuth token exchange failed.', 'Run: giz auth login')
118
+ error.oauthError = payload.error
119
+ throw error
107
120
  }
108
- const next = {
121
+ return {
109
122
  ...config,
123
+ baseUrl,
110
124
  ...payload,
111
125
  refresh_token: payload.refresh_token || config.refresh_token,
112
126
  expires_at: Date.now() + Number(payload.expires_in || 3600) * 1000
113
127
  }
114
- await saveConfig(next)
115
- return next
116
128
  }
117
129
 
118
- async function credentials(baseUrl, forceRefresh = false) {
130
+ async function credentials(baseUrl, rejectedToken) {
119
131
  if (process.env.GIZ_TOKEN) return { access_token: process.env.GIZ_TOKEN, baseUrl }
120
- let config = await readConfig()
121
- if (!config?.access_token) throw new CliError('Not authenticated.', 'Run: giz auth login')
132
+ const config = await readConfig()
133
+ if (!config?.access_token) {
134
+ const computer = await computerIdentity(rejectedToken)
135
+ if (!computer) throw new CliError('Not authenticated.', 'Run: giz auth login')
136
+ if (computer.baseUrl !== baseUrl) throw new CliError('컴퓨터 신원은 다른 서버에서 사용할 수 없습니다.', '사용자 계정으로 giz auth login을 실행해 주세요.')
137
+ return computer
138
+ }
122
139
  if (String(config.baseUrl).replace(/\/+$/, '') !== baseUrl) {
123
140
  throw new CliError(
124
141
  `Credentials belong to ${config.baseUrl}.`,
125
142
  `Run: giz auth login --base-url ${baseUrl}`
126
143
  )
127
144
  }
128
- if (forceRefresh || Number(config.expires_at || 0) <= Date.now() + 60_000) config = await refresh(config)
145
+ const expired = (value) => value.access_token === rejectedToken || Number(value.expires_at || 0) <= Date.now() + 60_000
146
+ if (expired(config)) {
147
+ return await updateConfig(async (config) => {
148
+ if (config?.baseUrl !== baseUrl) throw new CliError('Giz credentials changed.', 'Run: giz auth login')
149
+ if (expired(config)) {
150
+ if (!config.refresh_token) throw new CliError('Giz session expired.', 'Run: giz auth login')
151
+ return await exchangeToken(baseUrl, await discovery(baseUrl), {
152
+ grant_type: 'refresh_token', refresh_token: config.refresh_token
153
+ }, config)
154
+ }
155
+ return config
156
+ })
157
+ }
129
158
  return config
130
159
  }
131
160
 
132
- async function api(baseUrl, pathname, data, options = {}) {
133
- const run = async (forceRefresh = false) => {
134
- const auth = await credentials(baseUrl, forceRefresh)
135
- const response = await fetch(new URL(pathname, baseUrl), {
136
- method: data === undefined ? 'GET' : 'POST',
137
- headers: {
138
- accept: 'application/json',
139
- ...(data === undefined ? {} : { 'content-type': 'application/json' }),
140
- authorization: `Bearer ${auth.access_token}`
141
- },
142
- ...(data === undefined ? {} : { body: JSON.stringify(data) })
143
- })
144
- const text = await response.text()
145
- let payload
146
- try {
147
- payload = text ? JSON.parse(text) : null
148
- } catch {
149
- payload = { message: text.slice(0, 500) }
150
- }
151
- return { response, payload }
161
+ async function authenticatedFetch(baseUrl, url, init = {}) {
162
+ const headers = new Headers(init.headers)
163
+ let auth = await credentials(baseUrl)
164
+ const signal = AbortSignal.timeout(25 * 60_000)
165
+ const send = () => {
166
+ headers.set('authorization', `Bearer ${auth.access_token}`)
167
+ // The server resolves membership and roles; the CLI only names the organization, as the web's selector does.
168
+ if (process.env.GIZ_ORGANIZATION) headers.set('x-giz-organization', process.env.GIZ_ORGANIZATION)
169
+ return fetch(url, { ...init, headers, signal: init.signal ? AbortSignal.any([init.signal, signal]) : signal })
152
170
  }
153
- let result = await run()
154
- if (result.response.status === 401 && !process.env.GIZ_TOKEN && !options.refreshed) {
155
- result = await run(true)
171
+ let response = await send()
172
+ if (response.status === 401 && !process.env.GIZ_TOKEN) {
173
+ await response.body?.cancel()
174
+ auth = await credentials(baseUrl, auth.access_token)
175
+ response = await send()
156
176
  }
157
- if (!result.response.ok) {
158
- const required = result.response.status === 403 ? result.payload?.message : undefined
177
+ return response
178
+ }
179
+
180
+ async function jsonResponse(response, baseUrl) {
181
+ const text = await response.text()
182
+ let payload
183
+ try { payload = JSON.parse(text) } catch {
184
+ if (response.ok) throw new CliError('Giz API returned invalid JSON.', `Request: ${new URL(response.url).pathname}`)
185
+ payload = { message: text.slice(0, 500) }
186
+ }
187
+ if (!response.ok) {
188
+ const message = payload?.message || payload?.error?.message || `Giz API returned HTTP ${response.status}.`
189
+ // Older servers have only the legacy message; classify that exact contract without hiding other 404s.
190
+ if (response.status === 404 && (payload?.code === 'EXTERNAL_TOOL_UNAVAILABLE' ||
191
+ (new URL(response.url).pathname.startsWith('/api/tools/') && /^Unknown Giz tool: /.test(message)))) {
192
+ const compatibility = payload?.code === 'EXTERNAL_TOOL_UNAVAILABLE'
193
+ ? message : '서버 버전은 확인할 수 없습니다(구버전 서버).'
194
+ throw new CliError(
195
+ `이 서버는 요청한 명령을 지원하지 않습니다. CLI ${CLI_VERSION}. ${compatibility}`,
196
+ `서버 주소(${baseUrl})와 지원 버전을 확인해 주세요. 지원하는 서버는 --base-url로 선택할 수 있습니다.`,
197
+ response.status
198
+ )
199
+ }
159
200
  throw new CliError(
160
- result.payload?.message || `Giz API returned HTTP ${result.response.status}.`,
161
- required?.includes('scope') ? 'Run: giz auth login' : 'Run with --json for the complete error.',
162
- result.response.status
201
+ message,
202
+ response.status === 403 && /scope/i.test(message)
203
+ ? (process.env.GIZ_TOKEN ? 'Provide a GIZ_TOKEN with the required scope.' : `Run: giz auth login --base-url ${baseUrl}`)
204
+ : `Request: ${new URL(response.url).pathname}`,
205
+ response.status
163
206
  )
164
207
  }
165
- return result.payload
208
+ return payload
166
209
  }
167
210
 
168
- async function uploadMedia(baseUrl, localFile) {
169
- const filePath = path.resolve(String(localFile))
170
- const run = async (forceRefresh = false) => {
171
- const auth = await credentials(baseUrl, forceRefresh)
172
- let file
173
- try {
174
- file = await openAsBlob(filePath)
175
- } catch (error) {
176
- throw new CliError(`Could not open ${filePath}.`, String(error?.message || error))
177
- }
178
- const body = new FormData()
179
- body.append('file', file, path.basename(filePath))
180
- const response = await fetch(new URL('/api/storage/media', baseUrl), {
181
- method: 'POST',
182
- headers: {
183
- accept: 'application/json',
184
- authorization: `Bearer ${auth.access_token}`
185
- },
186
- body
187
- })
188
- const text = await response.text()
189
- let payload
190
- try {
191
- payload = text ? JSON.parse(text) : null
192
- } catch {
193
- payload = { message: text.slice(0, 500) }
211
+ async function api(baseUrl, pathname, data, options = {}) {
212
+ const response = await authenticatedFetch(baseUrl, new URL(pathname, baseUrl), {
213
+ method: options.method || (data === undefined ? 'GET' : 'POST'),
214
+ headers: { accept: 'application/json', ...(data === undefined ? {} : { 'content-type': 'application/json' }) },
215
+ ...(data === undefined ? {} : { body: JSON.stringify(data) })
216
+ })
217
+ const payload = await jsonResponse(response, baseUrl)
218
+ if (pathname === '/api/external/me' && payload.minimumCliVersion) {
219
+ const current = CLI_VERSION.split('.').map(Number)
220
+ const minimum = String(payload.minimumCliVersion).split('.').map(Number)
221
+ const differing = minimum.findIndex((part, index) => part !== current[index])
222
+ if (differing >= 0 && current[differing] < minimum[differing]) {
223
+ process.stderr.write(`Warning: Giz CLI ${CLI_VERSION} is below minimum ${payload.minimumCliVersion}; update @gizai/cli.\n`)
194
224
  }
195
- return { response, payload }
196
225
  }
197
- let result = await run()
198
- if (result.response.status === 401 && !process.env.GIZ_TOKEN) result = await run(true)
199
- if (!result.response.ok) {
200
- throw new CliError(
201
- result.payload?.message || `Giz upload returned HTTP ${result.response.status}.`,
202
- 'Check the file and your Giz storage access.'
203
- )
204
- }
205
- const url = String(result.payload?.url || '').trim()
206
- const drivePath = String(result.payload?.path || '').trim()
226
+ return payload
227
+ }
228
+
229
+ async function uploadMedia(baseUrl, localFile) {
230
+ const filePath = path.resolve(String(localFile))
231
+ const body = new FormData()
232
+ body.append('file', await openAsBlob(filePath), path.basename(filePath))
233
+ const response = await authenticatedFetch(baseUrl, new URL('/api/storage/media', baseUrl), {
234
+ method: 'POST', headers: { accept: 'application/json' }, body
235
+ })
236
+ const payload = await jsonResponse(response, baseUrl)
237
+ const url = String(payload?.url || '').trim()
238
+ const drivePath = String(payload?.path || '').trim()
207
239
  if (!url || !drivePath) throw new CliError('Giz upload returned an incomplete file reference.', 'Try the upload again.')
208
- return { ...result.payload, url: new URL(url, baseUrl).href }
240
+ return { ...payload, url: new URL(url, baseUrl).href }
209
241
  }
210
242
 
211
243
  async function login(baseUrl, options) {
212
244
  const metadata = await discovery(baseUrl)
245
+ if (options.device === true) return await deviceLogin(baseUrl, metadata)
213
246
  const verifier = randomBytes(48).toString('base64url')
214
247
  const challenge = createHash('sha256').update(verifier).digest('base64url')
215
248
  const state = randomBytes(24).toString('base64url')
216
- const callback = waitForCallback(state)
249
+ const callback = await waitForCallback(state)
217
250
  const authorization = new URL(metadata.authorization_endpoint)
218
251
  for (const [name, value] of Object.entries({
219
252
  response_type: 'code',
220
253
  client_id: CLIENT_ID,
221
- redirect_uri: CALLBACK_URL,
254
+ redirect_uri: callback.url,
222
255
  scope: SCOPES.join(' '),
223
256
  resource: new URL('/api/mcp', baseUrl).href,
224
257
  code_challenge: challenge,
@@ -226,76 +259,86 @@ async function login(baseUrl, options) {
226
259
  prompt: 'consent',
227
260
  state
228
261
  })) authorization.searchParams.set(name, value)
229
- process.stdout.write(`Opening Giz sign-in:\n${authorization}\n`)
262
+ process.stderr.write(`Opening Giz sign-in:\n${authorization}\n`)
230
263
  if (options.open !== 'false' && options['no-open'] !== true) openBrowser(authorization.href)
231
- const code = await callback
232
- const response = await fetch(metadata.token_endpoint, {
233
- method: 'POST',
234
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
235
- body: new URLSearchParams({
236
- grant_type: 'authorization_code',
237
- client_id: CLIENT_ID,
238
- redirect_uri: CALLBACK_URL,
239
- code,
240
- code_verifier: verifier,
241
- resource: new URL('/api/mcp', baseUrl).href
242
- })
264
+ const code = await callback.code
265
+ await updateConfig(() => exchangeToken(baseUrl, metadata, {
266
+ grant_type: 'authorization_code', redirect_uri: callback.url, code, code_verifier: verifier
267
+ }))
268
+ return await api(baseUrl, '/api/external/me')
269
+ }
270
+
271
+ async function deviceLogin(baseUrl, metadata) {
272
+ if (!metadata.device_authorization_endpoint) throw new CliError('OAuth device authorization is not supported.')
273
+ const response = await fetch(metadata.device_authorization_endpoint, {
274
+ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
275
+ body: new URLSearchParams({ client_id: CLIENT_ID, scope: SCOPES.join(' '), resource: new URL('/api/mcp', baseUrl).href }),
276
+ signal: AbortSignal.timeout(30_000)
243
277
  })
244
- const payload = await response.json()
245
- if (!response.ok) {
246
- throw new CliError(payload.error_description || payload.error || 'OAuth token exchange failed.', 'Run: giz auth login')
278
+ const device = await response.json()
279
+ if (!response.ok) throw new CliError(device.error_description || device.error || 'OAuth device authorization failed.')
280
+ if (!device.device_code || !device.user_code || !device.verification_uri || !(Number(device.expires_in) > 0)) {
281
+ throw new CliError('OAuth device authorization returned an incomplete response.')
247
282
  }
248
- await saveConfig({
249
- baseUrl,
250
- ...payload,
251
- expires_at: Date.now() + Number(payload.expires_in || 3600) * 1000
252
- })
253
- return await api(baseUrl, '/api/external/me')
283
+ process.stderr.write(`Giz sign-in: ${device.verification_uri_complete || device.verification_uri}\nCode: ${device.user_code}\n`)
284
+ let interval = Number(device.interval ?? 5) * 1000
285
+ if (!Number.isFinite(interval) || interval <= 0) throw new CliError('OAuth device authorization returned an invalid interval.')
286
+ const deadline = Date.now() + Number(device.expires_in) * 1000
287
+ while (Date.now() < deadline) {
288
+ const remaining = deadline - Date.now()
289
+ await new Promise((resolve) => setTimeout(resolve, Math.min(interval, remaining)))
290
+ if (interval >= remaining || Date.now() >= deadline) break
291
+ try {
292
+ await updateConfig(() => exchangeToken(baseUrl, metadata, {
293
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: device.device_code
294
+ }))
295
+ } catch (error) {
296
+ if (error.oauthError === 'slow_down') interval += 5000
297
+ else if (error.name === 'TimeoutError') interval *= 2
298
+ else if (error.oauthError !== 'authorization_pending') throw error
299
+ continue
300
+ }
301
+ return await api(baseUrl, '/api/external/me')
302
+ }
303
+ throw new CliError('expired_token: Giz device code expired.', 'Run: giz auth login --device')
254
304
  }
255
305
 
256
- function waitForCallback(expectedState) {
257
- return new Promise((resolve, reject) => {
258
- const timer = setTimeout(() => {
306
+ async function waitForCallback(expectedState) {
307
+ const server = createServer()
308
+ server.listen(0, '127.0.0.1')
309
+ await once(server, 'listening')
310
+ const callbackUrl = `http://127.0.0.1:${server.address().port}/callback`
311
+ const code = new Promise((resolve, reject) => {
312
+ const finish = (error, value) => {
313
+ clearTimeout(timer)
259
314
  server.close()
260
- reject(new CliError('Giz sign-in timed out.', 'Run: giz auth login'))
261
- }, 5 * 60 * 1000)
262
- const server = createServer((request, response) => {
263
- const url = new URL(request.url || '/', CALLBACK_URL)
315
+ error ? reject(error) : resolve(value)
316
+ }
317
+ const timer = setTimeout(() => finish(new CliError('Giz sign-in timed out.', 'Run: giz auth login')), 5 * 60 * 1000)
318
+ server.on('request', (request, response) => {
319
+ const url = new URL(request.url || '/', callbackUrl)
264
320
  if (url.pathname !== '/callback') {
265
321
  response.writeHead(404).end('Not found')
266
322
  return
267
323
  }
268
- const finish = (error, code) => {
269
- clearTimeout(timer)
270
- response.setHeader('content-type', 'text/html; charset=utf-8')
271
- response.end(error
272
- ? '<h1>Giz connection failed</h1><p>Return to the terminal for details.</p>'
273
- : '<h1>Giz connected</h1><p>You can close this window and return to the terminal.</p>')
274
- server.close()
275
- error ? reject(error) : resolve(code)
276
- }
277
324
  if (url.searchParams.get('state') !== expectedState) {
278
- finish(new CliError('OAuth state did not match.', 'Run: giz auth login'))
279
- } else if (url.searchParams.get('error')) {
280
- finish(new CliError(
281
- url.searchParams.get('error_description') || url.searchParams.get('error'),
282
- 'Run: giz auth login'
283
- ))
284
- } else if (!url.searchParams.get('code')) {
285
- finish(new CliError('OAuth callback did not include a code.', 'Run: giz auth login'))
286
- } else {
287
- finish(undefined, url.searchParams.get('code'))
325
+ response.writeHead(400).end('This sign-in link has expired. Use the link from your current giz auth login.')
326
+ return
288
327
  }
328
+ const code = url.searchParams.get('code')
329
+ const error = url.searchParams.get('error') || !code
330
+ ? new CliError(url.searchParams.get('error_description') || url.searchParams.get('error') || 'OAuth callback did not include a code.', 'Run: giz auth login')
331
+ : undefined
332
+ response.setHeader('content-type', 'text/html; charset=utf-8')
333
+ response.setHeader('connection', 'close')
334
+ response.end(error
335
+ ? '<h1>Giz connection failed</h1><p>Return to the terminal for details.</p>'
336
+ : '<h1>Giz connected</h1><p>You can close this window and return to the terminal.</p>')
337
+ finish(error, code)
289
338
  })
290
- server.on('error', (error) => {
291
- clearTimeout(timer)
292
- reject(new CliError(
293
- `Could not listen on ${CALLBACK_URL}: ${error.message}`,
294
- 'Close the process using port 53682 and run: giz auth login'
295
- ))
296
- })
297
- server.listen(53682, '127.0.0.1')
339
+ server.on('error', finish)
298
340
  })
341
+ return { url: callbackUrl, code }
299
342
  }
300
343
 
301
344
  function openBrowser(url) {
@@ -306,6 +349,23 @@ function openBrowser(url) {
306
349
  child.unref()
307
350
  }
308
351
 
352
+ // --input @FILE and --input - (stdin) carry JSON too large for one process argument (Linux caps one at 128 KiB).
353
+ async function inputText(value) {
354
+ if (value === '-') {
355
+ let text = ''
356
+ for await (const chunk of process.stdin) text += chunk
357
+ return text
358
+ }
359
+ if (typeof value === 'string' && value.startsWith('@')) {
360
+ try {
361
+ return await readFile(value.slice(1), 'utf8')
362
+ } catch {
363
+ throw new CliError(`Could not read ${value.slice(1)}.`, 'Pass --input @PATH to an existing JSON file.')
364
+ }
365
+ }
366
+ return value
367
+ }
368
+
309
369
  function jsonInput(value) {
310
370
  if (value === undefined || value === true) return {}
311
371
  try {
@@ -340,14 +400,66 @@ async function applyUploads(baseUrl, input, values = []) {
340
400
  return input
341
401
  }
342
402
 
403
+ // Mutations use the user's reviewed App revision; fetching it silently would bypass conflict protection.
404
+ function appRevision(options) {
405
+ const raw = options['expected-revision']
406
+ if (raw === undefined || raw === '') {
407
+ throw new CliError('개정 번호가 필요합니다.', 'giz app list에서 확인한 revision을 --expected-revision으로 전달해 주세요.')
408
+ }
409
+ const revision = Number(raw)
410
+ if (typeof raw !== 'string' || !/^\d+$/.test(raw) || !Number.isSafeInteger(revision) || revision < 1 || revision > 9_007_199_254_740_990) {
411
+ throw new CliError('개정 번호는 1 이상의 안전한 정수여야 합니다.', 'giz app list에서 현재 revision을 확인해 주세요.')
412
+ }
413
+ return revision
414
+ }
415
+
343
416
  async function invoke(baseUrl, tool, input) {
344
417
  return await api(baseUrl, `/api/tools/${encodeURIComponent(tool)}`, input)
345
418
  }
346
419
 
420
+ function mcpTransport(baseUrl) {
421
+ return new StreamableHTTPClientTransport(new URL('/api/mcp', baseUrl), {
422
+ fetch: (url, init) => authenticatedFetch(baseUrl, url, init)
423
+ })
424
+ }
425
+
426
+ async function serveMcp(baseUrl) {
427
+ const remote = mcpTransport(baseUrl)
428
+ const stdio = new StdioServerTransport()
429
+ try {
430
+ await remote.start()
431
+ await new Promise((resolve, reject) => {
432
+ remote.onerror = stdio.onerror = reject
433
+ remote.onmessage = (message) => {
434
+ if (message.result?.protocolVersion) remote.setProtocolVersion(message.result.protocolVersion)
435
+ stdio.send(message).catch(reject)
436
+ }
437
+ stdio.onmessage = (message) => { remote.send(message).catch(reject) }
438
+ process.stdin.once('end', resolve)
439
+ stdio.start().catch(reject)
440
+ })
441
+ } finally {
442
+ await remote.close()
443
+ await stdio.close()
444
+ }
445
+ }
446
+
347
447
  async function runCommand(baseUrl, positionals, options) {
348
448
  const [command, subcommand, ...rest] = positionals
349
- if (options.version === true) return { kind: 'message', value: CLI_VERSION }
449
+ if (options.version === true) {
450
+ const revision = await managedRevision()
451
+ return { kind: 'message', value: CLI_VERSION + (revision ? ` (region ${revision.trim()})` : '') }
452
+ }
350
453
  if (!command || command === 'help' || options.help === true) return { kind: 'help', value: help(command) }
454
+ if (command === 'engine' && subcommand === 'run') {
455
+ if (rest[0] !== 'claude') throw new CliError('Only Claude Code is an engine connection.')
456
+ await api(baseUrl, '/api/external/engine/claude/authorization')
457
+ const { runEngine } = await import('./engine.mjs')
458
+ const result = await runEngine(rest[0], required(rest[1], 'INSTRUCTION', 'Run: giz engine run claude "instruction"'), options.dir,
459
+ event => process.stdout.write(JSON.stringify(event) + '\n'))
460
+ if (result.status !== 'completed') process.exitCode = 1
461
+ return { kind: 'result', value: result }
462
+ }
351
463
  if (command === 'auth' && subcommand === 'login') {
352
464
  return { kind: 'auth', value: await login(baseUrl, options) }
353
465
  }
@@ -355,24 +467,43 @@ async function runCommand(baseUrl, positionals, options) {
355
467
  return { kind: 'auth', value: await api(baseUrl, '/api/external/me') }
356
468
  }
357
469
  if (command === 'auth' && subcommand === 'logout') {
358
- const config = await readConfig()
359
- if (config?.refresh_token) {
360
- const metadata = await discovery(baseUrl)
361
- await fetch(metadata.revocation_endpoint, {
362
- method: 'POST',
363
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
364
- body: new URLSearchParams({
365
- token: config.refresh_token,
366
- token_type_hint: 'refresh_token',
367
- client_id: CLIENT_ID
470
+ await updateConfig(async (config) => {
471
+ if (config?.refresh_token) {
472
+ const metadata = await discovery(config.baseUrl)
473
+ const response = await fetch(metadata.revocation_endpoint, {
474
+ method: 'POST', signal: AbortSignal.timeout(30_000),
475
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
476
+ body: new URLSearchParams({ token: config.refresh_token, token_type_hint: 'refresh_token', client_id: CLIENT_ID })
368
477
  })
369
- })
370
- }
371
- await unlink(configPath).catch((error) => {
372
- if (error?.code !== 'ENOENT') throw error
478
+ if (!response.ok) throw new CliError(`Giz sign-out failed: HTTP ${response.status}.`, 'Run: giz auth logout')
479
+ }
373
480
  })
374
481
  return { kind: 'message', value: 'Signed out of Giz on this device.' }
375
482
  }
483
+ if (command === 'api-key' && subcommand === 'list') {
484
+ return { kind: 'result', value: await api(baseUrl, '/api/external/api-keys') }
485
+ }
486
+ if (command === 'api-key' && subcommand === 'create') {
487
+ const name = required(rest.join(' ') || options.name, 'NAME', 'Run: giz api-key create "My app"')
488
+ const scopes = options.scopes
489
+ ? String(options.scopes).split(',').map((scope) => scope.trim()).filter(Boolean)
490
+ : undefined
491
+ return {
492
+ kind: 'result',
493
+ value: await api(baseUrl, '/api/external/api-keys', {
494
+ name,
495
+ ...(scopes ? { scopes } : {}),
496
+ ...(options.expires ? { expiresAt: options.expires } : {})
497
+ })
498
+ }
499
+ }
500
+ if (command === 'api-key' && subcommand === 'revoke') {
501
+ const id = required(rest[0], 'KEY', 'Run: giz api-key revoke key_...')
502
+ return {
503
+ kind: 'result',
504
+ value: await api(baseUrl, `/api/external/api-keys/${encodeURIComponent(id)}`, undefined, { method: 'DELETE' })
505
+ }
506
+ }
376
507
 
377
508
  if (command === 'model' && subcommand === 'list') {
378
509
  return {
@@ -395,6 +526,22 @@ async function runCommand(baseUrl, positionals, options) {
395
526
  if (command === 'models') {
396
527
  throw new CliError('Unknown command "models".', 'Did you mean: giz model list')
397
528
  }
529
+ if (command === 'chat') {
530
+ const model = required(options.model || subcommand, 'MODEL', 'Run: giz chat MODEL "your prompt"')
531
+ const input = jsonInput(options.input)
532
+ const prompt = rest.join(' ').trim()
533
+ const messages = input.messages || (prompt ? [{ role: 'user', content: prompt }] : undefined)
534
+ required(messages, 'MESSAGES', 'Pass a prompt or --input \'{"messages":[...]}\'')
535
+ return {
536
+ kind: 'result',
537
+ value: await api(baseUrl, '/v1/chat/completions', {
538
+ ...input,
539
+ model,
540
+ messages,
541
+ stream: false
542
+ })
543
+ }
544
+ }
398
545
 
399
546
  if (command === 'generate' && ['image', 'video', 'audio', 'music', 'talking-head'].includes(subcommand)) {
400
547
  const tool = {
@@ -414,7 +561,6 @@ async function runCommand(baseUrl, positionals, options) {
414
561
  ...(rest.length ? { prompt: rest.join(' ') } : {}),
415
562
  ...(options.model ? { model: options.model } : {})
416
563
  }, uploads)
417
- required(input.prompt, 'PROMPT', `Run: giz generate ${subcommand} "your prompt"`)
418
564
  return { kind: 'job', value: await invoke(baseUrl, tool, input) }
419
565
  }
420
566
 
@@ -433,7 +579,7 @@ async function runCommand(baseUrl, positionals, options) {
433
579
  const id = required(rest[0], 'JOB', 'Run: giz job wait JOB')
434
580
  while (true) {
435
581
  const job = await invoke(baseUrl, 'job.get', { job: id })
436
- if (['completed', 'failed', 'cancelled'].includes(job.status)) return { kind: 'job', value: job }
582
+ if (['completed', 'failed', 'cancelled'].includes(job.status)) return { kind: 'job', value: job, exitCode: job.status === 'completed' ? 0 : 1 }
437
583
  if (process.stderr.isTTY) process.stderr.write(`\r${job.id} ${job.status} ${job.message || ''}`.slice(0, 120))
438
584
  await new Promise((resolve) => setTimeout(resolve, 1000))
439
585
  }
@@ -467,16 +613,47 @@ async function runCommand(baseUrl, positionals, options) {
467
613
  path: drivePath,
468
614
  content: String(content),
469
615
  encoding,
470
- ...(options.computer ? { computerId: options.computer } : {})
616
+ ...(options.computer ? { computer: options.computer } : {})
471
617
  })
472
618
  }
473
619
  }
474
- const mapping = mappings[subcommand]
620
+ const mapping = mappings[subcommand === 'ls' ? 'list' : subcommand]
475
621
  if (!mapping) throw new CliError(`Unknown drive command "${subcommand || ''}".`, 'Run: giz drive --help')
476
622
  const [tool, input] = mapping()
477
623
  return {
478
624
  kind: 'result',
479
- value: await invoke(baseUrl, tool, { ...input, ...(options.computer ? { computerId: options.computer } : {}) })
625
+ value: await invoke(baseUrl, tool, { ...input, ...(options.computer ? { computer: options.computer } : {}) })
626
+ }
627
+ }
628
+
629
+ if (command === 'table' && subcommand === 'list') {
630
+ return { kind: 'result', value: await invoke(baseUrl, 'workspace.objects', {}) }
631
+ }
632
+
633
+ if (command === 'table' && subcommand === 'get') {
634
+ const object = required(rest[0], 'TABLE', 'Run: giz table get task')
635
+ return { kind: 'result', value: await invoke(baseUrl, 'workspace.objects', { object }) }
636
+ }
637
+
638
+ if (command === 'table' && subcommand === 'action') {
639
+ const view = required(rest[0], 'TABLE', 'Run: giz table action TABLE ACTION [--id ID] --input JSON')
640
+ const action = required(rest[1], 'ACTION', 'Run: giz table get TABLE to see its actions')
641
+ const id = options.id === undefined ? undefined : String(options.id).includes(',') ? String(options.id).split(',') : String(options.id)
642
+ return {
643
+ kind: 'result',
644
+ value: await invoke(baseUrl, 'workspace.executeAction', { view, action, ...(id === undefined ? {} : { dataId: id }), data: jsonInput(options.input) })
645
+ }
646
+ }
647
+
648
+ if (command === 'table' && ['read', 'write'].includes(subcommand)) {
649
+ const table = required(rest[0], 'TABLE', `Run: giz table ${subcommand} TABLE_PATH --input JSON`)
650
+ return {
651
+ kind: 'result',
652
+ value: await invoke(baseUrl, subcommand === 'read' ? 'table.readRows' : 'table.writeRows', {
653
+ ...jsonInput(options.input),
654
+ table,
655
+ ...(options.computer ? { computer: options.computer } : {})
656
+ })
480
657
  }
481
658
  }
482
659
 
@@ -487,28 +664,128 @@ async function runCommand(baseUrl, positionals, options) {
487
664
 
488
665
  if (command === 'computer') {
489
666
  if (subcommand === 'list') return { kind: 'computers', value: await invoke(baseUrl, 'computer.list', {}) }
490
- const computerId = rest[0] || options.computer
491
- if (subcommand === 'get') return { kind: 'result', value: await invoke(baseUrl, 'computer.get', computerId ? { computerId } : {}) }
667
+ const computer = options.computer || (subcommand === 'exec' ? undefined : rest[0])
668
+ if (subcommand === 'get') return { kind: 'result', value: await invoke(baseUrl, 'computer.get', computer ? { computer } : {}) }
492
669
  if (subcommand === 'start' || subcommand === 'stop') {
493
670
  return {
494
671
  kind: 'result',
495
- value: await invoke(baseUrl, `computer.${subcommand}`, computerId ? { computerId } : {})
672
+ value: await invoke(baseUrl, `computer.${subcommand}`, computer ? { computer } : {})
496
673
  }
497
674
  }
498
675
  if (subcommand === 'exec') {
499
- const commandArgs = rest
500
- if (!commandArgs.length) throw new CliError('COMMAND is required.', 'Run: giz computer exec -- ls -la')
676
+ if (!rest.length) throw new CliError('COMMAND is required.', 'Run: giz computer exec -- ls -la')
677
+ const stdin = options['stdin-file'] ? await readFile(String(options['stdin-file']), 'utf8') : options.stdin
678
+ const value = await invoke(baseUrl, 'computer.exec', {
679
+ command: rest,
680
+ ...(stdin !== undefined ? { stdin: String(stdin) } : {}),
681
+ ...(options['deadline-ms'] !== undefined ? { deadlineMs: Number(options['deadline-ms']) } : {}),
682
+ ...(options.cwd ? { cwd: options.cwd } : {}),
683
+ ...(computer ? { computer } : {})
684
+ })
685
+ return { kind: 'result', value, exitCode: value.exitCode }
686
+ }
687
+ throw new CliError(`Unknown computer command "${subcommand || ''}".`, 'Run: giz computer --help')
688
+ }
689
+
690
+ if (command === 'app') {
691
+ const computer = options.computer ? { computer: options.computer } : {}
692
+ if (subcommand === 'list') {
693
+ return { kind: 'result', value: await invoke(baseUrl, 'app.list', computer) }
694
+ }
695
+ if (subcommand === 'save' || subcommand === 'create') {
696
+ const input = jsonInput(options.input)
697
+ return { kind: 'result', value: await invoke(baseUrl, 'app.save', { ...input, ...computer }) }
698
+ }
699
+ if (subcommand === 'run') {
700
+ const id = required(rest[0], 'APP_ID', 'Run: giz app run APP_ID --expected-revision REVISION')
701
+ return { kind: 'result', value: await invoke(baseUrl, 'app.run', { ...computer, id, expectedRevision: appRevision(options) }) }
702
+ }
703
+ if (subcommand === 'publish') {
704
+ const id = required(rest[0], 'APP_ID', 'Run: giz app publish APP_ID private|workspace|public')
705
+ const visibility = required(rest[1] || options.visibility, 'VISIBILITY', 'Run: giz app publish APP_ID private|workspace|public')
706
+ return { kind: 'result', value: await invoke(baseUrl, 'app.publish', { ...computer, id, visibility, expectedRevision: appRevision(options) }) }
707
+ }
708
+ if (subcommand === 'open') {
709
+ const id = required(rest[0], 'APP_ID', 'Run: giz app open APP_ID')
710
+ return { kind: 'result', value: await invoke(baseUrl, 'app.open', { ...computer, id }) }
711
+ }
712
+ if (subcommand === 'delete') {
713
+ const id = required(rest[0], 'APP_ID', 'Run: giz app delete APP_ID')
714
+ return { kind: 'result', value: await invoke(baseUrl, 'app.delete', { ...computer, id, expectedRevision: appRevision(options) }) }
715
+ }
716
+ throw new CliError(`Unknown app command "${subcommand || ''}".`, 'Run: giz app --help')
717
+ }
718
+
719
+ if (command === 'capability') {
720
+ if (subcommand === 'list') {
501
721
  return {
502
722
  kind: 'result',
503
- value: await invoke(baseUrl, 'container.exec', {
504
- command: commandArgs,
505
- sessionId: String(options.session || 'giz-cli'),
506
- ...(options.cwd ? { cwd: options.cwd } : {}),
507
- ...(options.computer ? { computerId: options.computer } : {})
723
+ value: await invoke(baseUrl, 'capability.status', {
724
+ ...(options.computer ? { computer: options.computer } : {})
508
725
  })
509
726
  }
510
727
  }
511
- throw new CliError(`Unknown computer command "${subcommand || ''}".`, 'Run: giz computer --help')
728
+ if (subcommand === 'search') {
729
+ const kind = required(rest[0], 'KIND', 'Run: giz capability search skill|mcp QUERY')
730
+ const query = required(rest.slice(1).join(' '), 'QUERY', 'Run: giz capability search skill|mcp QUERY')
731
+ return {
732
+ kind: 'result',
733
+ value: await invoke(baseUrl, 'capability.catalog', {
734
+ kind,
735
+ query,
736
+ ...(options.limit ? { limit: Number(options.limit) } : {})
737
+ })
738
+ }
739
+ }
740
+ if (subcommand === 'install') {
741
+ const kind = required(rest[0], 'KIND', 'Run: giz capability install skill|mcp ID --query QUERY')
742
+ const id = required(rest[1], 'ID', 'Run: giz capability install skill|mcp ID --query QUERY')
743
+ const query = required(options.query, 'QUERY', 'Repeat the catalog query with --query QUERY.')
744
+ return {
745
+ kind: 'result',
746
+ value: await invoke(baseUrl, 'capability.install', {
747
+ kind,
748
+ id,
749
+ query,
750
+ ...(options.computer ? { computer: options.computer } : {})
751
+ })
752
+ }
753
+ }
754
+ if (subcommand === 'update' || subcommand === 'remove') {
755
+ const id = required(rest[0], 'ID', `Run: giz capability ${subcommand} ID`)
756
+ return {
757
+ kind: 'result',
758
+ value: await invoke(baseUrl, 'capability.manage', {
759
+ id,
760
+ operation: subcommand,
761
+ ...(options.computer ? { computer: options.computer } : {})
762
+ })
763
+ }
764
+ }
765
+ throw new CliError(`Unknown capability command "${subcommand || ''}".`, 'Run: giz capability --help')
766
+ }
767
+
768
+ if (command === 'agent') {
769
+ if (subcommand === 'start') {
770
+ return {
771
+ kind: 'result',
772
+ value: await invoke(baseUrl, 'agent.start', {
773
+ ...jsonInput(options.input),
774
+ ...(options.computer ? { computer: options.computer } : {})
775
+ })
776
+ }
777
+ }
778
+ if (subcommand === 'status' || subcommand === 'cancel') {
779
+ const turnId = required(rest[0], 'TURN', `Run: giz agent ${subcommand} TURN`)
780
+ return {
781
+ kind: 'result',
782
+ value: await invoke(baseUrl, `agent.${subcommand}`, {
783
+ turnId,
784
+ ...(subcommand === 'status' && options.wait ? { wait: true } : {})
785
+ })
786
+ }
787
+ }
788
+ throw new CliError(`Unknown agent command "${subcommand || ''}".`, 'Run: giz agent --help')
512
789
  }
513
790
 
514
791
  if (command === 'invoke') {
@@ -519,90 +796,133 @@ async function runCommand(baseUrl, positionals, options) {
519
796
  if (command === 'mcp' && subcommand === 'url') {
520
797
  return { kind: 'message', value: new URL('/api/mcp', baseUrl).href }
521
798
  }
799
+ if (command === 'mcp' && subcommand === 'serve') {
800
+ await serveMcp(baseUrl)
801
+ return
802
+ }
803
+ if (command === 'mcp' && subcommand === 'check') {
804
+ const client = new Client({ name: 'giz-cli', version: CLI_VERSION })
805
+ try {
806
+ await client.connect(mcpTransport(baseUrl))
807
+ const { tools } = await client.listTools()
808
+ if (!tools.length) throw new CliError('Giz MCP returned zero tools.', 'Check your Giz connection and scopes.')
809
+ return { kind: 'result', value: { server: client.getServerVersion(), tools: tools.map((tool) => tool.name) } }
810
+ } finally { await client.close() }
811
+ }
522
812
  if (command === 'mcp' && subcommand === 'add') {
523
813
  const client = required(rest[0], 'CLIENT', 'Run: giz mcp add codex|claude')
814
+ if (!['codex', 'claude'].includes(client)) throw new CliError(`Unsupported MCP client "${client}".`, 'Supported clients: codex, claude')
815
+ const transport = options.transport || 'stdio'
816
+ if (!['stdio', 'http'].includes(transport)) throw new CliError('MCP transport must be stdio or http.')
524
817
  const endpoint = new URL('/api/mcp', baseUrl).href
525
- if (client === 'codex') {
526
- runExternal('codex', ['mcp', 'add', 'giz', '--url', endpoint, '--oauth-resource', endpoint], 'Install Codex CLI first.')
527
- return { kind: 'message', value: 'Giz was added to Codex. Complete the browser approval opened by Codex.' }
528
- }
529
- if (client === 'claude') {
530
- runExternal('claude', ['mcp', 'add', '--transport', 'http', '--scope', 'user', 'giz', endpoint], 'Install Claude Code first.')
531
- return { kind: 'message', value: 'Giz was added to Claude Code. Open Claude Code and run /mcp to sign in.' }
532
- }
533
- throw new CliError(`Unsupported MCP client "${client}".`, 'Supported clients: codex, claude')
818
+ const target = transport === 'stdio'
819
+ ? ['--env', `GIZ_CONFIG=${configPath}`, '--', process.execPath, fileURLToPath(import.meta.url), 'mcp', 'serve', '--base-url', baseUrl]
820
+ : client === 'codex' ? ['--url', endpoint, '--oauth-resource', endpoint] : [endpoint]
821
+ runExternal(client, ['mcp', 'add', ...(client === 'claude' ? ['--scope', 'user', '--transport', transport] : []), 'giz', ...target],
822
+ `Install ${client === 'codex' ? 'Codex: npm install -g @openai/codex' : 'Claude Code: npm install -g @anthropic-ai/claude-code'}`, transport === 'http')
823
+ return { kind: 'message', value: `Giz was added to ${client} (${transport}). ${transport === 'stdio'
824
+ ? 'Run giz auth login, then giz mcp check --json before starting a headless session.'
825
+ : client === 'claude' ? 'Open Claude Code and run /mcp to sign in.' : 'Complete the browser approval opened by Codex.'}` }
534
826
  }
535
827
 
536
828
  if (command === 'skill' && subcommand === 'install') {
537
829
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'skills')
538
- runExternal('npx', ['--yes', 'skills', 'add', root, '--all'], 'Install Node.js 20 or newer first.')
830
+ runExternal('npx', ['--yes', 'skills', 'add', root, '--skill', '*', '--agent', 'universal', '--copy', '-y'], 'Install Node.js 20 or newer first.')
539
831
  return { kind: 'message', value: 'Giz Skill installed for available AI agents.' }
540
832
  }
541
833
 
542
834
  throw new CliError(`Unknown command "${[command, subcommand].filter(Boolean).join(' ')}".`, 'Run: giz --help')
543
835
  }
544
836
 
545
- function runExternal(command, args, hint) {
546
- const result = spawnSync(command, args, { stdio: 'inherit' })
837
+ function runExternal(command, args, hint, interactive = false) {
838
+ const result = spawnSync(command, args, { encoding: 'utf8', stdio: interactive ? ['inherit', 2, 2] : ['inherit', 'pipe', 'pipe'] })
547
839
  if (result.error?.code === 'ENOENT') throw new CliError(`${command} is not installed.`, hint)
548
- if (result.status !== 0) throw new CliError(`${command} exited with status ${result.status}.`, hint)
840
+ if (result.error || result.status !== 0) throw new CliError(
841
+ `${command} failed (${result.error?.code || result.signal || result.status}): ${(result.stderr || result.stdout || result.error?.message || '').trim()}`
842
+ )
843
+ if (result.stderr || result.stdout) process.stderr.write(result.stderr + result.stdout)
549
844
  }
550
845
 
551
- function print(kind, value, json) {
846
+ function print(kind, value, json, output = process.stdout) {
552
847
  if (json) {
553
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)
848
+ output.write(`${JSON.stringify(value, null, 2)}\n`)
554
849
  return
555
850
  }
556
851
  if (kind === 'help' || kind === 'message') {
557
- process.stdout.write(`${value}\n`)
852
+ output.write(`${value}\n`)
853
+ return
854
+ }
855
+ if (kind === 'auth' && value.identity?.type === 'computer') {
856
+ output.write(`컴퓨터 신원으로 로그인됨 (소유자 ${value.email || value.name})\n`)
558
857
  return
559
858
  }
560
859
  if (kind === 'models') {
561
860
  const rows = value.models || []
562
- for (const model of rows) process.stdout.write(`${model.id}\t${model.type}\t${model.provider || '-'}\t${model.label}\n`)
563
- process.stdout.write(`\n${rows.length} models\n`)
861
+ for (const model of rows) output.write(`${model.id}\t${model.type}\t${model.provider || '-'}\t${model.label}\n`)
862
+ output.write(`\n${rows.length} models\n`)
564
863
  return
565
864
  }
566
865
  if (kind === 'jobs') {
567
- for (const job of value.jobs || []) process.stdout.write(`${job.id}\t${job.status}\t${job.tool}\t${job.createdAt}\n`)
866
+ for (const job of value.jobs || []) output.write(`${job.id}\t${job.status}\t${job.tool}\t${job.createdAt}\n`)
568
867
  return
569
868
  }
570
869
  if (kind === 'computers') {
571
870
  for (const computer of value.computers || []) {
572
- process.stdout.write(`${computer.id}\t${computer.status || '-'}\t${computer.isDefault ? 'default' : '-'}\t${computer.name}\n`)
871
+ output.write(`${computer.id}\t${computer.status || '-'}\t${computer.isDefault ? 'default' : '-'}\t${computer.name}\n`)
573
872
  }
574
873
  return
575
874
  }
576
875
  if (kind === 'job') {
577
- process.stdout.write(`${value.id} ${value.status}\n`)
578
- if (value.message) process.stdout.write(`${value.message}\n`)
579
- if (value.error) process.stdout.write(`Error: ${value.error}\n`)
580
- if (value.result) process.stdout.write(`${JSON.stringify(value.result, null, 2)}\n`)
876
+ output.write(`${value.id} ${value.status}\n`)
877
+ if (value.message) output.write(`${value.message}\n`)
878
+ if (value.error) output.write(`Error: ${value.error}\n`)
879
+ if (value.result) output.write(`${JSON.stringify(value.result, null, 2)}\n`)
581
880
  if (!['completed', 'failed', 'cancelled'].includes(value.status)) {
582
- process.stdout.write(`Run: giz job wait ${value.id}\n`)
881
+ output.write(`Run: giz job wait ${value.id}\n`)
583
882
  }
584
883
  return
585
884
  }
586
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)
885
+ output.write(`${JSON.stringify(value, null, 2)}\n`)
587
886
  }
588
887
 
589
888
  function help(command) {
889
+ if (command === 'engine') return `Usage
890
+ giz engine run claude "instruction" [--dir path]
891
+
892
+ Runs the official Claude Code in an isolated worktree. Connect Claude Code in Giz first.
893
+ Progress and the final diff, files and summary are returned. Interrupting stops the engine.`
894
+ if (command === 'api-key') return `Usage
895
+ giz api-key list
896
+ giz api-key create "My app" [--scopes giz.inference,giz.read] [--expires ISO_TIMESTAMP]
897
+ giz api-key revoke KEY_ID
898
+
899
+ The secret is shown only when the key is created.`
900
+ if (command === 'chat') return `Usage
901
+ giz chat MODEL "your prompt"
902
+ giz chat MODEL --input '{"messages":[{"role":"user","content":"Hello"}]}'`
590
903
  if (command === 'model') return `Usage
591
904
  giz model list [--type TYPE] [--search TEXT]
592
905
  giz model get MODEL
593
906
  giz model run MODEL --input JSON [--upload FIELD=LOCAL_PATH]
594
907
 
595
- Run model get first. Its live input schema is the source of truth. Repeat --upload for multiple media inputs.`
908
+ Run model get first. Its live input schema is the source of truth. Repeat --upload for multiple media inputs.
909
+ model run returns a job immediately. Retrieve the result with giz job wait JOB_ID, or poll with giz job get JOB_ID.
910
+ For speech-to-text, use model run with the audio field returned by model get. Callback/webhook delivery is not supported.`
596
911
  if (command === 'generate') return `Usage
597
- giz generate image|video|audio|music|talking-head "PROMPT" [--model MODEL]
912
+ giz generate image|video|music|talking-head "PROMPT" [--model MODEL]
913
+ giz generate audio --input '{"purpose":"speech","script":"Hello"}' [--model MODEL]
914
+ giz generate audio --input '{"purpose":"sound","prompt":"Rain","duration":5}'
598
915
 
599
916
  Options
600
917
  --image LOCAL_PATH Upload a visual reference
601
918
  --audio LOCAL_PATH Upload speech audio
602
919
  --upload FIELD=PATH Upload into an exact tool field
603
- --input JSON Additional generation parameters`
920
+ --input JSON Input matching the tool schema (@FILE reads a file, - reads stdin)
921
+
922
+ For model-specific audio options, inspect giz model get MODEL and pass supported fields in settings (for example settings.speed), not at the top level.
923
+ Generation returns a job immediately; use giz job wait JOB_ID for its result.`
604
924
  if (command === 'drive') return `Usage
605
- giz drive list [PATH]
925
+ giz drive ls [PATH] (list alias)
606
926
  giz drive search QUERY
607
927
  giz drive read PATH
608
928
  giz drive write PATH --content TEXT
@@ -610,47 +930,81 @@ Options
610
930
  giz drive mkdir PATH
611
931
  giz drive move FROM TO
612
932
  giz drive delete PATH`
933
+ if (command === 'table') return `Usage
934
+ giz table list
935
+ giz table get TABLE
936
+ giz table read TABLE [--input JSON] [--computer COMPUTER]
937
+ giz table write TABLE --input JSON [--computer COMPUTER]
938
+ giz table action TABLE ACTION [--id ID[,ID...]] --input JSON
939
+
940
+ TABLE is a table's Drive path (for example Sales/Customers.view.ts) or a business object name from giz table list
941
+ (for example task, issue, requirement). giz table get shows its fields, the row changes it accepts (writes) and its
942
+ actions; an object without a write changes only through its actions. Use --org to work in an organization.
943
+ read input: {"select":["name"],"filters":[{"field":"status","operator":"eq","value":"open"}],"limit":50}
944
+ write input: {"create":[{"name":"Acme"}],"update":[{"id":"ROW_ID","status":"done"}],"remove":["ROW_ID"]}
945
+ One write is one change people can undo in the table. At most 200 rows per write.`
613
946
  if (command === 'computer') return `Usage
614
947
  giz computer list
615
948
  giz computer get|start|stop [COMPUTER]
616
- giz computer exec [--computer COMPUTER] [--cwd PATH] -- COMMAND [ARGS...]`
949
+ giz computer exec [--computer COMPUTER] [--cwd PATH] [--deadline-ms MS] [--stdin TEXT|--stdin-file PATH] -- COMMAND [ARGS...]`
950
+ if (command === 'app') return `Usage
951
+ giz app list [--computer COMPUTER]
952
+ giz app create|save [--computer COMPUTER] --input JSON
953
+ giz app run APP_ID --expected-revision REVISION [--computer COMPUTER]
954
+ giz app publish APP_ID private|workspace|public --expected-revision REVISION [--computer COMPUTER]
955
+ giz app open APP_ID [--computer COMPUTER]
956
+ giz app delete APP_ID --expected-revision REVISION [--computer COMPUTER]`
957
+ if (command === 'capability') return `Usage
958
+ giz capability list [--computer COMPUTER]
959
+ giz capability search skill|mcp QUERY
960
+ giz capability install skill|mcp ID --query QUERY [--computer COMPUTER]
961
+ giz capability update|remove ID [--computer COMPUTER]`
962
+ if (command === 'agent') return `Usage
963
+ giz agent start [--computer COMPUTER] --input JSON
964
+ giz agent status TURN [--wait]
965
+ giz agent cancel TURN`
617
966
  if (command === 'mcp') return `Usage
618
967
  giz mcp url
619
- giz mcp add codex
620
- giz mcp add claude`
968
+ giz mcp serve
969
+ giz mcp check --json
970
+ giz mcp add codex|claude [--transport stdio|http]
971
+
972
+ stdio (default) shares giz auth login and refreshes tokens automatically.
973
+ http uses the client's own OAuth login. Install the chosen client first.`
621
974
  if (command === 'auth') return `Usage
622
- giz auth login
975
+ giz auth login [--device]
623
976
  giz auth status
624
977
  giz auth logout`
625
- return `Giz CLI — non-LLM models, Drive, and Computer from any terminal
978
+ return `Giz CLI — AI models, Drive, and Computer from any terminal
626
979
 
627
980
  Usage
628
- giz auth login
981
+ giz auth login [--device]
982
+ giz api-key list|create|revoke
983
+ giz chat MODEL "your prompt"
629
984
  giz model list [--type TYPE] [--search TEXT]
630
985
  giz model get MODEL
631
986
  giz model run MODEL --input JSON [--upload FIELD=LOCAL_PATH]
632
- giz generate image|video|audio|music "PROMPT" [--model MODEL]
987
+ giz generate image|video|music|talking-head "PROMPT" [--model MODEL]
988
+ giz generate audio --input JSON [--model MODEL]
633
989
  giz job list|get|wait|cancel [JOB]
634
- giz drive list|search|read|write|mkdir|move|delete
990
+ giz drive ls|list|search|read|write|mkdir|move|delete
991
+ giz table list|get|read|write|action
635
992
  giz upload LOCAL_PATH
636
993
  giz computer list|get|start|stop|exec
637
- giz mcp add codex|claude
994
+ giz app list|create|save|run|publish|open|delete
995
+ giz capability list|search|install|update|remove
996
+ giz agent start|status|cancel
997
+ giz engine run claude "instruction" [--dir path]
998
+ giz mcp url|serve|check|add
638
999
  giz skill install
639
1000
  giz invoke TOOL --input JSON
640
1001
 
641
- Examples
642
- giz auth login
643
- giz generate image "A paper observatory at blue hour"
644
- giz model list --type video-generation
645
- giz model run ltx2-distilled --input '{"prompt":"A slow dolly in"}' --upload image=./first-frame.png
646
- giz generate talking-head "Welcome to Giz" --image ./face.png
647
- giz drive write notes/brief.md --file ./brief.md
648
- giz computer exec --computer ctr_123 -- python --version
649
- giz mcp add codex
1002
+ Use giz COMMAND --help for parameters and examples.
650
1003
 
651
1004
  Global options
652
1005
  --json Machine-readable JSON output
653
1006
  --base-url URL Another Giz deployment
1007
+ --org ORGANIZATION Organization id to work in (or GIZ_ORGANIZATION)
654
1008
  --no-open Print the login URL without opening a browser
655
1009
 
656
1010
  Docs: https://www.giz.ai/mcp/`
@@ -659,24 +1013,23 @@ Docs: https://www.giz.ai/mcp/`
659
1013
  async function main() {
660
1014
  const { positionals, options } = parse(process.argv.slice(2))
661
1015
  const saved = await readConfig()
662
- const baseUrl = String(options['base-url'] || process.env.GIZ_URL || saved?.baseUrl || DEFAULT_URL).replace(/\/+$/, '')
1016
+ const requiresAuth = positionals.length && !options.help && !options.version &&
1017
+ !(positionals[0] === 'auth' && ['login', 'logout'].includes(positionals[1]))
1018
+ const computer = requiresAuth && !process.env.GIZ_TOKEN && !saved?.access_token ? await computerIdentity() : undefined
1019
+ const baseUrl = String(options['base-url'] || process.env.GIZ_URL || saved?.baseUrl || computer?.baseUrl || DEFAULT_URL).replace(/\/+$/, '')
1020
+ if (typeof options.org === 'string') process.env.GIZ_ORGANIZATION = options.org
1021
+ options.input = await inputText(options.input)
663
1022
  const output = await runCommand(baseUrl, positionals, options)
664
- print(output.kind, output.value, options.json === true)
1023
+ if (output) {
1024
+ print(output.kind, output.value, options.json === true)
1025
+ process.exitCode = output.exitCode ?? (output.value?.isError ? 1 : 0)
1026
+ }
665
1027
  }
666
1028
 
667
1029
  main().catch((error) => {
668
1030
  const { options } = parse(process.argv.slice(2))
669
- if (options.json === true) {
670
- process.stderr.write(`${JSON.stringify({
671
- error: {
672
- message: String(error?.message || error),
673
- ...(error?.hint ? { hint: error.hint } : {}),
674
- ...(error?.status ? { status: error.status } : {})
675
- }
676
- }, null, 2)}\n`)
677
- } else {
678
- process.stderr.write(`Error: ${String(error?.message || error)}\n`)
679
- if (error?.hint) process.stderr.write(`Hint: ${error.hint}\n`)
680
- }
1031
+ const json = options.json === true
1032
+ const failure = { message: String(error?.message || error), hint: error?.hint, status: error?.status }
1033
+ print('message', json ? { error: failure } : `Error: ${failure.message}${failure.hint ? `\nHint: ${failure.hint}` : ''}`, json, process.stderr)
681
1034
  process.exitCode = 1
682
1035
  })