F8: distribution and GitHub-Releases self-update

- .github/workflows/release.yml: on tag v* build all 5 targets (macOS
  arm64/x86_64, Linux arm64/x86_64 incl. free arm runners, Windows
  x86_64) with the release-dist profile, archive kigi-<version>-<triple>
  with LICENSE/NOTICE/THIRD-PARTY-NOTICES, generate SHA256SUMS, publish
  the release (prerelease for tags containing '-'), with a tag↔workspace
  version guard.
- install.sh / install.ps1 (repo root): platform detection, latest or
  --version download from GitHub Releases, SHA-256 verification against
  SHA256SUMS, install into the kigi home's downloads/ + bin/kigi symlink
  (the same layout the self-updater manages), smoke test, PATH guidance.
- kigi-update rewritten onto the GitHub Releases API (documented wire
  shape; stable=/latest, alpha=semver-max across the list, pinned=/tags):
  SHA-256 gate before any binary swap, tar.gz/zip extraction per
  platform, atomic bin/kigi symlink swap, channel/rollback semantics and
  the KIGI_AUTO_UPDATE gate preserved verbatim; every x.ai/GCS/npm
  endpoint deleted, npm/gh-release installers removed, legacy grok/agent
  links retired on install. kigi-env owns the update base URL with a
  KIGI_UPDATE_BASE_URL override (this is what the test artifact server
  injects).
- .cargo/config.toml: removed the non-portable neoverse-v2 CPU pin on
  Linux arm64 (fleet-specific); RELRO/NX hardening link-args now apply
  to the gnu targets too, matching the release-dist profile's contract.
- THIRD-PARTY-NOTICES regenerated via cargo-about (about.toml +
  template); the M0 hand-built file is dropped and README points at the
  generated one. docs/RELEASE.md carries the release checklist.
- Deleted xAI-era leftovers: kigi-tui/scripts/install*.{sh,ps1} (x.ai
  CDN) and the @xai-official/grok npm skeleton (PRD F8: no npm).

Gates: fmt clean; workspace check/clippy 0/0 (--locked, -D warnings);
kigi-update 58 lib + 86 integration tests green; deny ok;
release-dist build of kigi-bin succeeds and reports 'kigi 0.1.0'.
This commit is contained in:
2026-07-18 00:54:53 -04:00
parent 5e4e24db99
commit 86e3724310
57 changed files with 27431 additions and 27787 deletions
@@ -1,4 +0,0 @@
bin/*.br
bin/grok
bin/grok.exe
THIRD_PARTY_NOTICES.md
@@ -1,11 +0,0 @@
# @xai-official/grok-darwin-arm64
Platform-specific binary for [`@xai-official/grok`](https://www.npmjs.com/package/@xai-official/grok) on darwin-arm64.
Do not install this package directly. Install the main package instead:
```sh
npm install -g @xai-official/grok
```
The main package will automatically pull the correct binary for your platform via `optionalDependencies`.
@@ -1,19 +0,0 @@
{
"name": "@xai-official/grok-darwin-arm64",
"version": "0.2.0-dev",
"description": "darwin-arm64 binary for @xai-official/grok. Do not install directly; install @xai-official/grok instead.",
"license": "Apache-2.0",
"files": [
"bin/",
"THIRD_PARTY_NOTICES.md"
],
"os": [
"darwin"
],
"cpu": [
"arm64"
],
"publishConfig": {
"access": "public"
}
}
@@ -1,4 +0,0 @@
bin/*.br
bin/grok
bin/grok.exe
THIRD_PARTY_NOTICES.md
@@ -1,11 +0,0 @@
# @xai-official/grok-darwin-x64
Platform-specific binary for [`@xai-official/grok`](https://www.npmjs.com/package/@xai-official/grok) on darwin-x64.
Do not install this package directly. Install the main package instead:
```sh
npm install -g @xai-official/grok
```
The main package will automatically pull the correct binary for your platform via `optionalDependencies`.
@@ -1,19 +0,0 @@
{
"name": "@xai-official/grok-darwin-x64",
"version": "0.2.0-dev",
"description": "darwin-x64 binary for @xai-official/grok. Do not install directly; install @xai-official/grok instead.",
"license": "Apache-2.0",
"files": [
"bin/",
"THIRD_PARTY_NOTICES.md"
],
"os": [
"darwin"
],
"cpu": [
"x64"
],
"publishConfig": {
"access": "public"
}
}
@@ -1,4 +0,0 @@
bin/*.br
bin/grok
bin/grok.exe
THIRD_PARTY_NOTICES.md
@@ -1,11 +0,0 @@
# @xai-official/grok-linux-arm64
Platform-specific binary for [`@xai-official/grok`](https://www.npmjs.com/package/@xai-official/grok) on linux-arm64.
Do not install this package directly. Install the main package instead:
```sh
npm install -g @xai-official/grok
```
The main package will automatically pull the correct binary for your platform via `optionalDependencies`.
@@ -1,19 +0,0 @@
{
"name": "@xai-official/grok-linux-arm64",
"version": "0.2.0-dev",
"description": "linux-arm64 binary for @xai-official/grok. Do not install directly; install @xai-official/grok instead.",
"license": "Apache-2.0",
"files": [
"bin/",
"THIRD_PARTY_NOTICES.md"
],
"os": [
"linux"
],
"cpu": [
"arm64"
],
"publishConfig": {
"access": "public"
}
}
@@ -1,4 +0,0 @@
bin/*.br
bin/grok
bin/grok.exe
THIRD_PARTY_NOTICES.md
@@ -1,11 +0,0 @@
# @xai-official/grok-linux-x64
Platform-specific binary for [`@xai-official/grok`](https://www.npmjs.com/package/@xai-official/grok) on linux-x64.
Do not install this package directly. Install the main package instead:
```sh
npm install -g @xai-official/grok
```
The main package will automatically pull the correct binary for your platform via `optionalDependencies`.
@@ -1,19 +0,0 @@
{
"name": "@xai-official/grok-linux-x64",
"version": "0.2.0-dev",
"description": "linux-x64 binary for @xai-official/grok. Do not install directly; install @xai-official/grok instead.",
"license": "Apache-2.0",
"files": [
"bin/",
"THIRD_PARTY_NOTICES.md"
],
"os": [
"linux"
],
"cpu": [
"x64"
],
"publishConfig": {
"access": "public"
}
}
@@ -1,4 +0,0 @@
bin/*.br
bin/grok
bin/grok.exe
THIRD_PARTY_NOTICES.md
@@ -1,11 +0,0 @@
# @xai-official/grok-win32-arm64
Platform-specific binary for [`@xai-official/grok`](https://www.npmjs.com/package/@xai-official/grok) on win32-arm64.
Do not install this package directly. Install the main package instead:
```sh
npm install -g @xai-official/grok
```
The main package will automatically pull the correct binary for your platform via `optionalDependencies`.
@@ -1,19 +0,0 @@
{
"name": "@xai-official/grok-win32-arm64",
"version": "0.2.0-dev",
"description": "win32-arm64 binary for @xai-official/grok. Do not install directly; install @xai-official/grok instead.",
"license": "Apache-2.0",
"files": [
"bin/",
"THIRD_PARTY_NOTICES.md"
],
"os": [
"win32"
],
"cpu": [
"arm64"
],
"publishConfig": {
"access": "public"
}
}
@@ -1,4 +0,0 @@
bin/*.br
bin/grok
bin/grok.exe
THIRD_PARTY_NOTICES.md
@@ -1,11 +0,0 @@
# @xai-official/grok-win32-x64
Platform-specific binary for [`@xai-official/grok`](https://www.npmjs.com/package/@xai-official/grok) on win32-x64.
Do not install this package directly. Install the main package instead:
```sh
npm install -g @xai-official/grok
```
The main package will automatically pull the correct binary for your platform via `optionalDependencies`.
@@ -1,19 +0,0 @@
{
"name": "@xai-official/grok-win32-x64",
"version": "0.2.0-dev",
"description": "win32-x64 binary for @xai-official/grok. Do not install directly; install @xai-official/grok instead.",
"license": "Apache-2.0",
"files": [
"bin/",
"THIRD_PARTY_NOTICES.md"
],
"os": [
"win32"
],
"cpu": [
"x64"
],
"publishConfig": {
"access": "public"
}
}
@@ -1,61 +0,0 @@
# Grok
Bring Grok into your terminal. Fast, flicker-free CLI built for plans, subagents, and parallel work.
**[Homepage](https://x.ai/cli)** | **[Documentation](https://docs.x.ai/build/overview)**
## Install
```bash
curl -fsSL https://x.ai/cli/install.sh | bash
```
Or install with npm:
```bash
npm i -g @xai-official/grok
```
## Get Started
```bash
# Launch the interactive TUI
grok
# Run a single task
grok -p "Explain this codebase"
```
On first launch, Grok opens your browser to authenticate. For CI or headless environments, use an API key from [console.x.ai](https://console.x.ai):
```bash
export XAI_API_KEY="xai-..."
```
## Update
```bash
grok update
```
Or if installed via npm:
```bash
npm i -g @xai-official/grok@latest
```
## Supported Platforms
| Platform | Architecture |
|---|---|
| macOS | Apple Silicon (arm64) |
| Linux | x86_64, arm64 |
| Windows | x86_64 |
## Documentation
For full documentation including configuration, MCP servers, custom models, headless mode, agent mode, and more, visit [docs.x.ai/build/overview](https://docs.x.ai/build/overview).
## Feedback
Run `/feedback` inside Grok to report issues or send feedback directly.
-136
View File
@@ -1,136 +0,0 @@
#!/usr/bin/env node
// Thin trampoline: resolves the grok binary from the matching per-platform
// optional dependency package and execs it.
//
// Falls back to bootstrapping the canonical ~/.grok/bin/grok-<version> symlink
// layout if postinstall hasn't run (e.g. npx, or postinstall failure).
//
// Binary location strategy (in priority order):
// 1. ~/.grok/bin/grok — canonical versioned symlink (postinstall.js)
// 2. @xai-official/grok-<platform>/bin/grok[.exe] — decompressed sibling
// 3. @xai-official/grok-<platform>/bin/grok[.exe].br — brotli-compressed
//
// Per-platform binaries are shipped brotli-compressed to stay well under
// npm's ~200 MB tarball ceiling. See sibling packages @xai-official/grok-*.
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const os = require('os');
const zlib = require('zlib');
const pkgName = '@xai-official/grok';
const IS_WINDOWS = process.platform === 'win32';
const EXE = IS_WINDOWS ? '.exe' : '';
const BIN_NAME = `grok${EXE}`;
const CANONICAL_DIR = path.join(os.homedir(), '.grok', 'bin');
const CANONICAL_PATH = path.join(CANONICAL_DIR, BIN_NAME);
function readLocalVersion() {
try { return require('../package.json').version; } catch { return undefined; }
}
// Resolve the per-platform sibling package's directory. Returns null if the
// matching optional dependency wasn't installed (unsupported platform, or
// npm refused to install optionalDependencies).
function resolvePlatformPackageDir() {
const platformPkg = `@xai-official/grok-${process.platform}-${process.arch}`;
try {
return path.dirname(require.resolve(`${platformPkg}/package.json`));
} catch {
return null;
}
}
// Decompress a brotli-compressed binary to a sibling path. Atomic via tmp+rename.
function decompressBrotli(brPath, outPath) {
const compressed = fs.readFileSync(brPath);
const decompressed = zlib.brotliDecompressSync(compressed);
const tmp = outPath + `.tmp.${process.pid}`;
fs.writeFileSync(tmp, decompressed);
if (!IS_WINDOWS) fs.chmodSync(tmp, 0o755);
try { fs.renameSync(tmp, outPath); } catch {}
}
// Bootstrap the canonical versioned-symlink layout from a source binary.
// Returns the canonical path on success, or the source path on failure.
function bootstrapCanonical(sourceBinPath, version) {
try {
fs.mkdirSync(CANONICAL_DIR, { recursive: true });
const versionedName = `grok-${version}${EXE}`;
const versionedPath = path.join(CANONICAL_DIR, versionedName);
if (!fs.existsSync(versionedPath)) {
const tmpPath = versionedPath + `.tmp.${process.pid}`;
fs.copyFileSync(sourceBinPath, tmpPath);
if (!IS_WINDOWS) fs.chmodSync(tmpPath, 0o755);
fs.renameSync(tmpPath, versionedPath);
}
if (IS_WINDOWS) {
const oldPath = CANONICAL_PATH + '.old';
try { fs.unlinkSync(oldPath); } catch {}
try {
try { fs.unlinkSync(CANONICAL_PATH); } catch {}
fs.copyFileSync(versionedPath, CANONICAL_PATH);
} catch {
fs.renameSync(CANONICAL_PATH, oldPath);
try {
fs.copyFileSync(versionedPath, CANONICAL_PATH);
} catch {
try { fs.renameSync(oldPath, CANONICAL_PATH); } catch {}
throw new Error('locked');
}
}
} else {
const tmpLink = CANONICAL_PATH + `.link.${process.pid}`;
try { fs.unlinkSync(tmpLink); } catch {}
fs.symlinkSync(versionedName, tmpLink);
fs.renameSync(tmpLink, CANONICAL_PATH);
}
return CANONICAL_PATH;
} catch {
return sourceBinPath;
}
}
function resolveBinary() {
if (fs.existsSync(CANONICAL_PATH)) return CANONICAL_PATH;
const platformDir = resolvePlatformPackageDir();
if (!platformDir) {
console.error(`${pkgName}: no platform binary installed for ${process.platform}-${process.arch}.`);
console.error(` Expected sibling package @xai-official/grok-${process.platform}-${process.arch}.`);
console.error(` This usually means npm skipped optionalDependencies (e.g. --no-optional)`);
console.error(` or the platform is not supported.`);
process.exit(1);
}
const rawPath = path.join(platformDir, 'bin', BIN_NAME);
const brPath = rawPath + '.br';
// Decompress on first use if needed (atomic via tmp+rename).
if (!fs.existsSync(rawPath)) {
if (fs.existsSync(brPath)) {
decompressBrotli(brPath, rawPath);
}
}
if (!fs.existsSync(rawPath)) {
console.error(`${pkgName}: missing binary at ${rawPath}`);
process.exit(1);
}
const version = readLocalVersion();
if (version) {
return bootstrapCanonical(rawPath, version);
}
return rawPath;
}
const execPath = resolveBinary();
const childEnv = { ...process.env, KIGI_MANAGED_BY_NPM: '1' };
const child = spawn(execPath, process.argv.slice(2), { stdio: 'inherit', env: childEnv });
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
} else {
process.exit(code ?? 0);
}
});
@@ -1,225 +0,0 @@
#!/usr/bin/env node
// Runs once after npm install/update. Reads the grok binary from the
// matching per-platform optional dependency (@xai-official/grok-<platform>)
// and installs it to ~/.grok/bin/ using versioned filenames:
//
// Unix: grok-<version> + grok (symlink)
// Windows: grok-<version>.exe + grok.exe (copy)
//
// Versioned files ensure running processes are never disrupted on macOS
// (replacing a binary that a running process has mmap'd causes SIGKILL
// because the kernel can no longer verify the code signature).
const path = require('path');
const fs = require('fs');
const os = require('os');
const zlib = require('zlib');
const { execSync } = require('child_process');
const TOML = require('@iarna/toml');
const CANONICAL_DIR = path.join(os.homedir(), '.grok', 'bin');
const key = `${process.platform}-${process.arch}`;
const SUPPORTED = new Set([
'darwin-arm64',
'darwin-x64',
'linux-x64',
'linux-arm64',
'win32-x64',
'win32-arm64',
]);
if (!SUPPORTED.has(key)) {
console.error(`@xai-official/grok: unsupported platform ${key}`);
process.exit(0);
}
// Resolve the per-platform sibling package's directory. The matching
// optionalDependency is installed by npm based on `os`/`cpu` filters; the
// other five are silently skipped. If the matching one is missing, npm was
// likely invoked with --no-optional or the platform is unsupported.
function resolvePlatformPackageDir() {
const platformPkg = `@xai-official/grok-${key}`;
try {
return path.dirname(require.resolve(`${platformPkg}/package.json`));
} catch {
return null;
}
}
let version;
try { version = require('../package.json').version; } catch {}
if (!version) {
console.error('@xai-official/grok: unable to determine version');
process.exit(0);
}
const IS_WINDOWS = process.platform === 'win32';
const EXE = IS_WINDOWS ? '.exe' : '';
fs.mkdirSync(CANONICAL_DIR, { recursive: true });
// Install a vendored binary: versioned filename + symlink (Unix) or copy (Windows).
// Binaries are shipped brotli-compressed in the per-platform npm tarball to keep
// each sub-package well under npm's ~200 MB tarball limit. This function
// decompresses them before installing into the canonical layout.
function installBinary(binName, sourceDir, vendorSubpath) {
const brPath = path.join(sourceDir, 'bin', vendorSubpath + '.br');
const rawPath = path.join(sourceDir, 'bin', vendorSubpath);
let vendoredBinPath;
if (fs.existsSync(brPath)) {
const compressed = fs.readFileSync(brPath);
const decompressed = zlib.brotliDecompressSync(compressed);
vendoredBinPath = rawPath;
fs.writeFileSync(vendoredBinPath, decompressed);
if (!IS_WINDOWS) fs.chmodSync(vendoredBinPath, 0o755);
try { fs.unlinkSync(brPath); } catch {}
} else if (fs.existsSync(rawPath)) {
vendoredBinPath = rawPath;
} else {
console.error(`@xai-official/grok: missing binary at ${brPath}`);
return false;
}
const versionedName = `${binName}-${version}${EXE}`;
const versionedPath = path.join(CANONICAL_DIR, versionedName);
const canonicalName = `${binName}${EXE}`;
const canonicalPath = path.join(CANONICAL_DIR, canonicalName);
// Only copy if this exact version isn't already installed.
if (!fs.existsSync(versionedPath)) {
const tmpPath = versionedPath + `.tmp.${process.pid}`;
try {
fs.copyFileSync(vendoredBinPath, tmpPath);
if (!IS_WINDOWS) fs.chmodSync(tmpPath, 0o755);
fs.renameSync(tmpPath, versionedPath);
} finally {
try { fs.unlinkSync(tmpPath); } catch {}
}
}
if (IS_WINDOWS) {
// Symlinks need elevation on Windows; copy instead. If the exe is
// locked by a running process, rename it aside then retry.
const oldPath = canonicalPath + '.old';
try { fs.unlinkSync(oldPath); } catch {} // stale backup from prior update
try {
try { fs.unlinkSync(canonicalPath); } catch {}
fs.copyFileSync(versionedPath, canonicalPath);
} catch (e) {
try {
fs.renameSync(canonicalPath, oldPath);
try {
fs.copyFileSync(versionedPath, canonicalPath);
} catch (copyErr) {
// Rollback: restore the old binary so the install isn't broken.
try { fs.renameSync(oldPath, canonicalPath); } catch {}
throw copyErr;
}
} catch (e2) {
console.error(`@xai-official/grok: failed to update ${canonicalPath}: ${e2.message}`);
console.error('Close all running grok processes and try again.');
return false;
}
}
} else {
// Atomic symlink swap.
const tmpLink = canonicalPath + `.link.${process.pid}`;
try { fs.unlinkSync(tmpLink); } catch {}
fs.symlinkSync(versionedName, tmpLink);
fs.renameSync(tmpLink, canonicalPath);
}
console.log(`${binName} ${version} installed to ${canonicalPath} -> ${versionedName}`);
return true;
}
// Best-effort cleanup of old versioned binaries for a given binary name.
// Keeps the current version and the previous one (in case a process is still
// running the old binary and hasn't fully loaded all pages yet).
// Uses an exact prefix match + hyphen + digit to avoid grok-* matching grok-pager-*.
function cleanupOldVersions(binName) {
try {
const prefix = `${binName}-`;
const currentVersioned = `${binName}-${version}${EXE}`;
const entries = fs.readdirSync(CANONICAL_DIR);
const versionedBinaries = entries
.filter(e => {
if (!e.startsWith(prefix)) return false;
if (e.includes('.tmp.') || e.includes('.link.')) return false;
if (e === currentVersioned) return false;
const suffix = e.slice(prefix.length);
return /^\d/.test(suffix);
})
.sort((a, b) => {
const pa = a.slice(prefix.length).split('.').map(Number);
const pb = b.slice(prefix.length).split('.').map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] || 0) !== (pb[i] || 0)) return (pb[i] || 0) - (pa[i] || 0);
}
return 0;
});
for (const old of versionedBinaries.slice(1)) {
try { fs.unlinkSync(path.join(CANONICAL_DIR, old)); } catch {}
}
} catch {}
}
const platformDir = resolvePlatformPackageDir();
if (!platformDir) {
console.error(`@xai-official/grok: platform package @xai-official/grok-${key} not installed.`);
console.error(' This usually means npm was invoked with --no-optional, or the install failed.');
console.error(' Try: npm install -g @xai-official/grok');
process.exit(0);
}
installBinary('grok', platformDir, `grok${EXE}`);
cleanupOldVersions('grok');
cleanupOldVersions('grok-pager');
// Write installer config
const configDir = path.join(os.homedir(), '.grok');
const configPath = path.join(configDir, 'config.toml');
let obj = {};
try { obj = TOML.parse(fs.readFileSync(configPath, 'utf8')); } catch { }
obj.cli ??= {};
obj.cli.installer = 'npm';
// Persist the npm registry so `grok update` and the trampoline use the same one.
const npmRegistry = process.env.KIGI_NPM_REGISTRY
|| (() => {
try {
const resolved = execSync(
'npm config get @xai-official:registry',
{ encoding: 'utf8', timeout: 5000 }
).trim();
if (resolved && resolved !== 'undefined') return resolved;
} catch {}
return null;
})();
if (npmRegistry) {
obj.cli.npm_registry = npmRegistry;
}
fs.writeFileSync(configPath, TOML.stringify(obj), 'utf8');
// Shell completions: print setup hints (no silent shell config mutation).
// Set KIGI_INSTALL_COMPLETIONS=1 to auto-generate to ~/.grok/completions.
const KIGI_PATH = path.join(CANONICAL_DIR, `grok${EXE}`);
if (process.env.KIGI_INSTALL_COMPLETIONS === '1' && !IS_WINDOWS) {
try {
const { spawnSync } = require('child_process');
const completionsDir = path.join(os.homedir(), '.grok', 'completions');
const bashPath = path.join(completionsDir, 'bash', 'grok.bash');
const zshPath = path.join(completionsDir, 'zsh', '_grok');
fs.mkdirSync(path.dirname(bashPath), { recursive: true });
fs.mkdirSync(path.dirname(zshPath), { recursive: true });
const bashRes = spawnSync(KIGI_PATH, ['completions', 'bash'], { encoding: 'utf8' });
if (bashRes.status === 0) fs.writeFileSync(bashPath, bashRes.stdout);
const zshRes = spawnSync(KIGI_PATH, ['completions', 'zsh'], { encoding: 'utf8' });
if (zshRes.status === 0) fs.writeFileSync(zshPath, zshRes.stdout);
console.log('Completions generated to ~/.grok/completions (bash/zsh)');
} catch {}
} else if (!IS_WINDOWS) {
console.log('Tip: grok completions bash > ~/.local/share/bash-completion/completions/grok');
console.log(' grok completions zsh > ~/.zsh/completions/_grok');
}
@@ -1,41 +0,0 @@
{
"name": "@xai-official/grok",
"version": "0.1.220-alpha.4",
"description": "Bring Grok into your terminal",
"license": "Apache-2.0",
"bin": {
"grok": "bin/grok"
},
"files": [
"bin/"
],
"os": [
"darwin",
"linux",
"win32"
],
"cpu": [
"arm64",
"x64"
],
"scripts": {
"postinstall": "node bin/postinstall.js"
},
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"@iarna/toml": "^3.0.0"
},
"optionalDependencies": {
"@xai-official/grok-darwin-arm64": "0.1.220-alpha.4",
"@xai-official/grok-darwin-x64": "0.1.220-alpha.4",
"@xai-official/grok-linux-arm64": "0.1.220-alpha.4",
"@xai-official/grok-linux-x64": "0.1.220-alpha.4",
"@xai-official/grok-win32-arm64": "0.1.220-alpha.4",
"@xai-official/grok-win32-x64": "0.1.220-alpha.4"
}
}
@@ -1,134 +0,0 @@
#!/usr/bin/env node
// Assemble the six per-platform npm packages prior to `npm publish`.
//
// For each supported (platform, arch) target this:
// 1. Brotli-compresses the built binary into `../grok-<platform>/bin/<bin>.br`
// 2. Stamps the sub-package's version to match the meta package
//
// Each per-platform package is its own npm publish target. The meta package
// (`@xai-official/grok`) lists all six as `optionalDependencies` pinned to
// the same version; npm installs only the one matching the host's
// `os` + `cpu` filters.
//
// Why brotli? npm's tarball ceiling is ~200 MB and the raw grok binary is
// 100150 MB per platform. Brotli at max quality cuts that to 3040 MB,
// leaves plenty of headroom for binary growth, and is decoded by Node's
// built-in zlib.brotliDecompressSync (no native deps required).
//
// Source paths come from environment variables (set in CI) and fall back to
// the default cargo target dirs for local testing.
const fs = require('fs');
const path = require('path');
const { promisify } = require('util');
const zlib = require('zlib');
const brotliCompress = promisify(zlib.brotliCompress);
const xaiRoot = process.env.XAI_ROOT || path.resolve(__dirname, '..', '..', '..', '..', '..');
const npmRoot = path.resolve(__dirname, '..', '..');
const NOTICES_SOURCE = path.resolve(
npmRoot, '..', '..', 'kigi-tools', 'THIRD_PARTY_NOTICES.md');
const NOTICES_NAME = 'THIRD_PARTY_NOTICES.md';
const META_PKG_JSON = path.resolve(__dirname, '..', 'package.json');
const meta = JSON.parse(fs.readFileSync(META_PKG_JSON, 'utf8'));
const VERSION = meta.version;
function ensureDir(p) { fs.mkdirSync(path.dirname(p), { recursive: true }); }
async function packPlatform({ platform, arch, envVar, defaultSource, binName }) {
const pkgDir = path.join(npmRoot, `grok-${platform}-${arch}`);
const pkgJsonPath = path.join(pkgDir, 'package.json');
if (!fs.existsSync(pkgJsonPath)) {
console.error(`[assemble] Missing per-platform package at ${pkgDir}`);
return false;
}
const source = process.env[envVar] || defaultSource;
if (!fs.existsSync(source)) {
console.error(`[assemble] Missing binary for ${platform}-${arch}: ${source}`);
console.error(` Set ${envVar} or build to the default location.`);
return false;
}
// Stamp the sub-package's version to match the meta package.
const subPkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
subPkg.version = VERSION;
fs.writeFileSync(pkgJsonPath, JSON.stringify(subPkg, null, 4) + '\n');
if (!fs.existsSync(NOTICES_SOURCE)) {
console.error(`[assemble] Missing third-party notices file: ${NOTICES_SOURCE}`);
return false;
}
fs.copyFileSync(NOTICES_SOURCE, path.join(pkgDir, NOTICES_NAME));
// Brotli-compress into the sub-package's bin/.
const outBr = path.join(pkgDir, 'bin', `${binName}.br`);
ensureDir(outBr);
const raw = fs.readFileSync(source);
const compressed = await brotliCompress(raw, {
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY },
});
fs.writeFileSync(outBr, compressed);
console.log(
`[assemble] grok-${platform}-${arch}@${VERSION}: ` +
`${(raw.length / 1048576).toFixed(1)} MB -> ${(compressed.length / 1048576).toFixed(1)} MB ` +
`(${path.relative(npmRoot, outBr)})`
);
return true;
}
async function main() {
const targets = [
{
platform: 'darwin', arch: 'arm64', binName: 'grok',
envVar: 'KIGI_DARWIN_ARM64',
defaultSource: path.join(xaiRoot, 'target', 'release', 'kigi-tui'),
},
{
platform: 'darwin', arch: 'x64', binName: 'grok',
envVar: 'KIGI_DARWIN_X64',
defaultSource: path.join(xaiRoot, 'target', 'x86_64-apple-darwin', 'release', 'kigi-tui'),
},
{
platform: 'linux', arch: 'x64', binName: 'grok',
envVar: 'KIGI_LINUX_X64',
defaultSource: path.join(xaiRoot, 'target',
'explorer_cross_x86_64-unknown-linux-gnu',
'x86_64-unknown-linux-gnu', 'release', 'kigi-tui'),
},
{
platform: 'linux', arch: 'arm64', binName: 'grok',
envVar: 'KIGI_LINUX_ARM64',
defaultSource: path.join(xaiRoot, 'target',
'explorer_cross_aarch64-unknown-linux-gnu',
'aarch64-unknown-linux-gnu', 'release', 'kigi-tui'),
},
{
platform: 'win32', arch: 'x64', binName: 'grok.exe',
envVar: 'KIGI_WIN32_X64',
defaultSource: path.join(xaiRoot, 'target', 'x86_64-pc-windows-msvc', 'release', 'kigi-tui.exe'),
},
{
platform: 'win32', arch: 'arm64', binName: 'grok.exe',
envVar: 'KIGI_WIN32_ARM64',
defaultSource: path.join(xaiRoot, 'target', 'aarch64-pc-windows-msvc', 'release', 'kigi-tui.exe'),
},
];
// Compress in parallel — brotliCompress runs on the libuv thread pool so
// calls genuinely overlap (set UV_THREADPOOL_SIZE>=6 in CI for full
// parallelism; Node's default pool size is 4).
const results = await Promise.all(targets.map(packPlatform));
const failed = results.filter(r => !r).length;
if (failed > 0) {
console.error(`[assemble] ${failed} target(s) failed.`);
process.exit(1);
}
console.log(`[assemble] All 6 per-platform packages assembled at version ${VERSION}.`);
}
main().catch((err) => { console.error(err); process.exit(1); });
@@ -1,983 +0,0 @@
#!/usr/bin/env node
// Tests for the versioned-binary + symlink installation logic used by
// postinstall.js and the bin/grok trampoline.
//
// Run with: node scripts/test-postinstall.js
//
// Uses only Node.js built-in modules (no test framework needed).
const fs = require('fs');
const path = require('path');
const os = require('os');
const assert = require('assert');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed++;
} catch (e) {
console.error(`${name}`);
console.error(` ${e.message}`);
failed++;
}
}
function makeTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'grok-test-'));
}
function cleanup(dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
// ─── Extracted logic (mirrors postinstall.js and bin/grok exactly) ─────
/** Semver-aware descending sort for "grok-X.Y.Z" filenames. */
function semverSortDescending(a, b) {
const pa = a.slice(5).split('.').map(Number);
const pb = b.slice(5).split('.').map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] || 0) !== (pb[i] || 0)) return (pb[i] || 0) - (pa[i] || 0);
}
return 0;
}
/** Install a versioned binary + atomic symlink (same as postinstall.js). */
function installVersionedBinary(vendoredBinPath, version, canonicalDir) {
const canonicalPath = path.join(canonicalDir, 'grok');
fs.mkdirSync(canonicalDir, { recursive: true });
const versionedName = `grok-${version}`;
const versionedPath = path.join(canonicalDir, versionedName);
if (!fs.existsSync(versionedPath)) {
const tmpPath = versionedPath + `.tmp.${process.pid}`;
try {
fs.copyFileSync(vendoredBinPath, tmpPath);
fs.chmodSync(tmpPath, 0o755);
fs.renameSync(tmpPath, versionedPath);
} finally {
try { fs.unlinkSync(tmpPath); } catch {}
}
}
const tmpLink = canonicalPath + `.link.${process.pid}`;
try { fs.unlinkSync(tmpLink); } catch {}
fs.symlinkSync(versionedName, tmpLink);
fs.renameSync(tmpLink, canonicalPath);
return { canonicalPath, versionedPath, versionedName };
}
/** Cleanup old versioned binaries (same as postinstall.js). */
function cleanupOldVersions(canonicalDir, currentVersionedName) {
const entries = fs.readdirSync(canonicalDir);
const versionedBinaries = entries
.filter(e => e.startsWith('grok-') && !e.includes('.tmp.') && !e.includes('.link.') && e !== currentVersionedName)
.sort(semverSortDescending);
// Keep the most recent old version, remove anything older.
for (const old of versionedBinaries.slice(1)) {
try { fs.unlinkSync(path.join(canonicalDir, old)); } catch {}
}
return versionedBinaries;
}
/** Bootstrap canonical from vendored (same as bin/grok trampoline). */
function bootstrapCanonical(vendoredBinPath, version, canonicalDir) {
const canonicalPath = path.join(canonicalDir, 'grok');
try {
fs.mkdirSync(canonicalDir, { recursive: true });
const versionedName = `grok-${version}`;
const versionedPath = path.join(canonicalDir, versionedName);
if (!fs.existsSync(versionedPath)) {
const tmpPath = versionedPath + `.tmp.${process.pid}`;
fs.copyFileSync(vendoredBinPath, tmpPath);
fs.chmodSync(tmpPath, 0o755);
fs.renameSync(tmpPath, versionedPath);
}
const tmpLink = canonicalPath + `.link.${process.pid}`;
try { fs.unlinkSync(tmpLink); } catch {}
fs.symlinkSync(versionedName, tmpLink);
fs.renameSync(tmpLink, canonicalPath);
return canonicalPath;
} catch {
return vendoredBinPath;
}
}
// ═══════════════════════════════════════════════════════════════════════
// Install + Symlink Tests
// ═══════════════════════════════════════════════════════════════════════
console.log('install + symlink tests\n');
test('creates versioned binary and symlink on fresh install', () => {
const dir = makeTmpDir();
try {
const vendored = path.join(dir, 'vendored-grok');
fs.writeFileSync(vendored, 'binary-content-v1');
const binDir = path.join(dir, 'bin');
const result = installVersionedBinary(vendored, '0.1.140', binDir);
// Versioned file should exist
assert.ok(fs.existsSync(result.versionedPath), 'versioned binary should exist');
assert.strictEqual(fs.readFileSync(result.versionedPath, 'utf8'), 'binary-content-v1');
// Canonical path should be a symlink
const stat = fs.lstatSync(result.canonicalPath);
assert.ok(stat.isSymbolicLink(), 'canonical path should be a symlink');
// Symlink should point to the versioned name (relative)
const target = fs.readlinkSync(result.canonicalPath);
assert.strictEqual(target, 'grok-0.1.140');
// Reading through the symlink should return the binary content
assert.strictEqual(fs.readFileSync(result.canonicalPath, 'utf8'), 'binary-content-v1');
} finally {
cleanup(dir);
}
});
test('upgrade swaps symlink and preserves old binary', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
// Install v1
const vendored_v1 = path.join(dir, 'vendored-v1');
fs.writeFileSync(vendored_v1, 'v1-content');
installVersionedBinary(vendored_v1, '0.1.140', binDir);
// Install v2
const vendored_v2 = path.join(dir, 'vendored-v2');
fs.writeFileSync(vendored_v2, 'v2-content');
const result = installVersionedBinary(vendored_v2, '0.1.141', binDir);
// Symlink now points to v2
assert.strictEqual(fs.readlinkSync(result.canonicalPath), 'grok-0.1.141');
assert.strictEqual(fs.readFileSync(result.canonicalPath, 'utf8'), 'v2-content');
// Old v1 binary MUST still exist on disk (this is the key safety property)
const oldBinary = path.join(binDir, 'grok-0.1.140');
assert.ok(fs.existsSync(oldBinary), 'old versioned binary must not be deleted');
assert.strictEqual(fs.readFileSync(oldBinary, 'utf8'), 'v1-content');
} finally {
cleanup(dir);
}
});
test('idempotent: reinstalling same version does not re-copy', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored');
fs.writeFileSync(vendored, 'original');
installVersionedBinary(vendored, '0.1.140', binDir);
// Modify vendored source (simulate npm replacing it)
fs.writeFileSync(vendored, 'replaced-by-npm');
// Re-run postinstall with same version
installVersionedBinary(vendored, '0.1.140', binDir);
// Versioned binary should NOT have been replaced (existsSync guard)
const versionedPath = path.join(binDir, 'grok-0.1.140');
assert.strictEqual(fs.readFileSync(versionedPath, 'utf8'), 'original');
} finally {
cleanup(dir);
}
});
test('symlink swap is atomic (no intermediate missing state)', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const canonicalPath = path.join(binDir, 'grok');
const vendored = path.join(dir, 'vendored');
fs.writeFileSync(vendored, 'v1');
installVersionedBinary(vendored, '0.1.140', binDir);
assert.ok(fs.existsSync(canonicalPath), 'should exist after first install');
// Upgrade
fs.writeFileSync(vendored, 'v2');
installVersionedBinary(vendored, '0.1.141', binDir);
assert.ok(fs.existsSync(canonicalPath), 'should exist after upgrade');
// No temp files left behind
const entries = fs.readdirSync(binDir);
const tempFiles = entries.filter(e => e.includes('.tmp.') || e.includes('.link.'));
assert.strictEqual(tempFiles.length, 0, `temp files should be cleaned up, found: ${tempFiles}`);
} finally {
cleanup(dir);
}
});
test('handles upgrade from old-style regular file to versioned symlink', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const canonicalPath = path.join(binDir, 'grok');
fs.mkdirSync(binDir, { recursive: true });
// Simulate old installation: grok is a regular file
fs.writeFileSync(canonicalPath, 'old-style-binary');
assert.ok(!fs.lstatSync(canonicalPath).isSymbolicLink(), 'should be regular file initially');
// Run new-style install
const vendored = path.join(dir, 'vendored');
fs.writeFileSync(vendored, 'v2-content');
installVersionedBinary(vendored, '0.1.141', binDir);
// Should now be a symlink
assert.ok(fs.lstatSync(canonicalPath).isSymbolicLink(), 'should be symlink after install');
assert.strictEqual(fs.readFileSync(canonicalPath, 'utf8'), 'v2-content');
} finally {
cleanup(dir);
}
});
test('handles broken symlink (target deleted externally)', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
// Create a broken symlink (points to a file that doesn't exist)
const canonicalPath = path.join(binDir, 'grok');
fs.symlinkSync('grok-0.1.99', canonicalPath);
assert.ok(!fs.existsSync(canonicalPath), 'broken symlink should not "exist"');
// Install should work and fix the broken symlink
const vendored = path.join(dir, 'vendored');
fs.writeFileSync(vendored, 'fixed-content');
const result = installVersionedBinary(vendored, '0.1.141', binDir);
assert.ok(fs.existsSync(result.canonicalPath), 'symlink should now resolve');
assert.strictEqual(fs.readFileSync(result.canonicalPath, 'utf8'), 'fixed-content');
} finally {
cleanup(dir);
}
});
test('three sequential upgrades: v1 -> v2 -> v3 all coexist', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored');
fs.writeFileSync(vendored, 'content-v1');
installVersionedBinary(vendored, '0.1.1', binDir);
fs.writeFileSync(vendored, 'content-v2');
installVersionedBinary(vendored, '0.1.2', binDir);
fs.writeFileSync(vendored, 'content-v3');
installVersionedBinary(vendored, '0.1.3', binDir);
// Symlink points to latest
assert.strictEqual(fs.readlinkSync(path.join(binDir, 'grok')), 'grok-0.1.3');
// All three versioned binaries still exist (no cleanup yet)
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.1')));
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.2')));
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.3')));
} finally {
cleanup(dir);
}
});
test('file permissions are preserved (0o755)', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored');
fs.writeFileSync(vendored, 'binary');
const result = installVersionedBinary(vendored, '0.1.140', binDir);
const mode = fs.statSync(result.versionedPath).mode & 0o777;
assert.strictEqual(mode, 0o755, `expected 0755, got ${mode.toString(8)}`);
} finally {
cleanup(dir);
}
});
// ═══════════════════════════════════════════════════════════════════════
// Cleanup / Semver Sort Tests
// ═══════════════════════════════════════════════════════════════════════
console.log('\ncleanup + semver sort tests\n');
test('cleanup keeps N-1 version and removes older ones', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
// Create three old versioned binaries
fs.writeFileSync(path.join(binDir, 'grok-0.1.138'), 'v138');
fs.writeFileSync(path.join(binDir, 'grok-0.1.139'), 'v139');
fs.writeFileSync(path.join(binDir, 'grok-0.1.140'), 'v140');
// grok-0.1.141 is the current version (excluded from cleanup)
fs.writeFileSync(path.join(binDir, 'grok-0.1.141'), 'v141');
cleanupOldVersions(binDir, 'grok-0.1.141');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.141')), 'current should exist');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.140')), 'N-1 should be kept');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-0.1.139')), 'N-2 should be removed');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-0.1.138')), 'N-3 should be removed');
} finally {
cleanup(dir);
}
});
test('cleanup with only one old version keeps it', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(path.join(binDir, 'grok-0.1.140'), 'v140');
fs.writeFileSync(path.join(binDir, 'grok-0.1.141'), 'v141');
cleanupOldVersions(binDir, 'grok-0.1.141');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.140')), 'single old version should be kept');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.141')), 'current should exist');
} finally {
cleanup(dir);
}
});
test('cleanup with no old versions is a no-op', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
// Only the current version exists
fs.writeFileSync(path.join(binDir, 'grok-0.1.141'), 'v141');
cleanupOldVersions(binDir, 'grok-0.1.141');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.141')), 'current should still exist');
const entries = fs.readdirSync(binDir).filter(e => e.startsWith('grok-'));
assert.strictEqual(entries.length, 1, 'should only have current version');
} finally {
cleanup(dir);
}
});
test('cleanup ignores .tmp. and .link. files', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(path.join(binDir, 'grok-0.1.141'), 'current');
// Leftover temp files from a crashed install
fs.writeFileSync(path.join(binDir, 'grok-0.1.140.tmp.12345'), 'crashed-tmp');
fs.writeFileSync(path.join(binDir, 'grok.link.12345'), 'crashed-link');
cleanupOldVersions(binDir, 'grok-0.1.141');
// Temp files should not be touched by cleanup (they're filtered out)
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.140.tmp.12345')), 'tmp file should not be touched');
assert.ok(fs.existsSync(path.join(binDir, 'grok.link.12345')), 'link file should not be touched');
} finally {
cleanup(dir);
}
});
test('semver sort: 0.1.9 vs 0.1.10 (digit boundary)', () => {
// Regression test: lexical sort puts '0.1.9' after '0.1.10' because '9' > '1'
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(path.join(binDir, 'grok-0.1.8'), 'v8');
fs.writeFileSync(path.join(binDir, 'grok-0.1.9'), 'v9');
fs.writeFileSync(path.join(binDir, 'grok-0.1.10'), 'v10');
fs.writeFileSync(path.join(binDir, 'grok-0.1.11'), 'v11');
cleanupOldVersions(binDir, 'grok-0.1.11');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.11')), 'current should exist');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.10')), '0.1.10 should be kept (N-1)');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-0.1.9')), '0.1.9 should be removed');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-0.1.8')), '0.1.8 should be removed');
} finally {
cleanup(dir);
}
});
test('semver sort: major version boundary (0.x vs 1.x)', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(path.join(binDir, 'grok-0.9.99'), 'old');
fs.writeFileSync(path.join(binDir, 'grok-1.0.0'), 'v1');
fs.writeFileSync(path.join(binDir, 'grok-1.0.1'), 'current');
cleanupOldVersions(binDir, 'grok-1.0.1');
assert.ok(fs.existsSync(path.join(binDir, 'grok-1.0.0')), '1.0.0 should be kept (N-1)');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-0.9.99')), '0.9.99 should be removed');
} finally {
cleanup(dir);
}
});
test('semver sort: minor version boundary (0.1.x vs 0.2.x)', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(path.join(binDir, 'grok-0.1.999'), 'old');
fs.writeFileSync(path.join(binDir, 'grok-0.2.0'), 'v2');
fs.writeFileSync(path.join(binDir, 'grok-0.2.1'), 'current');
cleanupOldVersions(binDir, 'grok-0.2.1');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.2.0')), '0.2.0 should be kept (N-1)');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-0.1.999')), '0.1.999 should be removed');
} finally {
cleanup(dir);
}
});
test('semverSortDescending: unit test comparator directly', () => {
const input = ['grok-0.1.9', 'grok-0.1.10', 'grok-0.1.2', 'grok-1.0.0', 'grok-0.2.0'];
const sorted = [...input].sort(semverSortDescending);
assert.deepStrictEqual(sorted, [
'grok-1.0.0',
'grok-0.2.0',
'grok-0.1.10',
'grok-0.1.9',
'grok-0.1.2',
]);
});
// ═══════════════════════════════════════════════════════════════════════
// Bootstrap (trampoline) Tests
// ═══════════════════════════════════════════════════════════════════════
console.log('\nbootstrap (trampoline) tests\n');
test('bootstrapCanonical creates versioned binary from vendored', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored-grok');
fs.writeFileSync(vendored, 'vendored-content');
const result = bootstrapCanonical(vendored, '0.1.140', binDir);
assert.strictEqual(result, path.join(binDir, 'grok'));
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.140')), 'versioned binary should exist');
assert.ok(fs.lstatSync(result).isSymbolicLink(), 'canonical should be symlink');
assert.strictEqual(fs.readFileSync(result, 'utf8'), 'vendored-content');
} finally {
cleanup(dir);
}
});
test('bootstrapCanonical is idempotent', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored-grok');
fs.writeFileSync(vendored, 'original-content');
bootstrapCanonical(vendored, '0.1.140', binDir);
// Change vendored content (simulating npm update)
fs.writeFileSync(vendored, 'npm-replaced-content');
// Second bootstrap should not overwrite existing versioned binary
const result = bootstrapCanonical(vendored, '0.1.140', binDir);
assert.strictEqual(
fs.readFileSync(path.join(binDir, 'grok-0.1.140'), 'utf8'),
'original-content',
'should keep original, not npm-replaced version'
);
} finally {
cleanup(dir);
}
});
test('bootstrapCanonical returns vendored path on failure', () => {
// If the canonical dir can't be created (e.g. permission denied),
// bootstrap should gracefully fall back to the vendored binary.
const dir = makeTmpDir();
try {
const vendored = path.join(dir, 'vendored');
fs.writeFileSync(vendored, 'fallback');
// Create a regular file where the dir should be — mkdirSync will fail.
const blockerFile = path.join(dir, 'blocked');
fs.writeFileSync(blockerFile, 'I am a file, not a directory');
const impossibleDir = path.join(blockerFile, 'subdir');
const result = bootstrapCanonical(vendored, '0.1.140', impossibleDir);
assert.strictEqual(result, vendored, 'should fall back to vendored path');
} finally {
cleanup(dir);
}
});
test('bootstrapCanonical works when canonical already exists (different version)', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
// Install v1 via postinstall
const vendored1 = path.join(dir, 'vendored-v1');
fs.writeFileSync(vendored1, 'v1');
installVersionedBinary(vendored1, '0.1.140', binDir);
// Bootstrap with v2 (simulates trampoline running a newer vendored binary)
const vendored2 = path.join(dir, 'vendored-v2');
fs.writeFileSync(vendored2, 'v2');
const result = bootstrapCanonical(vendored2, '0.1.141', binDir);
assert.strictEqual(result, path.join(binDir, 'grok'));
// Symlink should now point to v2
assert.strictEqual(fs.readlinkSync(result), 'grok-0.1.141');
// v1 should still exist
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.140')), 'old version should still exist');
} finally {
cleanup(dir);
}
});
// ═══════════════════════════════════════════════════════════════════════
// End-to-end Scenario Tests
// ═══════════════════════════════════════════════════════════════════════
console.log('\nend-to-end scenario tests\n');
test('full lifecycle: install, upgrade, cleanup', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored');
// v1: fresh install
fs.writeFileSync(vendored, 'v1');
installVersionedBinary(vendored, '0.1.140', binDir);
// v2: upgrade
fs.writeFileSync(vendored, 'v2');
installVersionedBinary(vendored, '0.1.141', binDir);
// v3: another upgrade
fs.writeFileSync(vendored, 'v3');
installVersionedBinary(vendored, '0.1.142', binDir);
cleanupOldVersions(binDir, 'grok-0.1.142');
// Current (v3) + N-1 (v2) should exist; v1 removed
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.142')), 'v3 should exist');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.141')), 'v2 should be kept (N-1)');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-0.1.140')), 'v1 should be removed');
// Canonical symlink points to v3
assert.strictEqual(fs.readlinkSync(path.join(binDir, 'grok')), 'grok-0.1.142');
assert.strictEqual(fs.readFileSync(path.join(binDir, 'grok'), 'utf8'), 'v3');
} finally {
cleanup(dir);
}
});
test('downgrade: installing older version than current', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored');
// Install v2 first
fs.writeFileSync(vendored, 'v2');
installVersionedBinary(vendored, '0.1.141', binDir);
// Downgrade to v1
fs.writeFileSync(vendored, 'v1');
installVersionedBinary(vendored, '0.1.140', binDir);
// Symlink should now point to v1
assert.strictEqual(fs.readlinkSync(path.join(binDir, 'grok')), 'grok-0.1.140');
assert.strictEqual(fs.readFileSync(path.join(binDir, 'grok'), 'utf8'), 'v1');
// v2 should still exist (never delete old binaries during install)
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.141')), 'v2 should still exist');
} finally {
cleanup(dir);
}
});
test('non-grok files in bin dir are not touched by cleanup', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
// Non-grok files
fs.writeFileSync(path.join(binDir, 'other-tool'), 'should-stay');
fs.writeFileSync(path.join(binDir, 'README.md'), 'should-stay');
// Grok versions
fs.writeFileSync(path.join(binDir, 'grok-0.1.138'), 'old1');
fs.writeFileSync(path.join(binDir, 'grok-0.1.139'), 'old2');
fs.writeFileSync(path.join(binDir, 'grok-0.1.140'), 'current');
cleanupOldVersions(binDir, 'grok-0.1.140');
assert.ok(fs.existsSync(path.join(binDir, 'other-tool')), 'non-grok file should not be touched');
assert.ok(fs.existsSync(path.join(binDir, 'README.md')), 'non-grok file should not be touched');
} finally {
cleanup(dir);
}
});
// ═══════════════════════════════════════════════════════════════════════
// grok vs grok-pager Isolation Tests
// ═══════════════════════════════════════════════════════════════════════
console.log('\ngrok vs grok-pager isolation tests\n');
/**
* Cleanup for a named binary (mirrors postinstall.js cleanupOldVersions).
* Uses prefix + leading digit to avoid grok-* matching grok-pager-*.
*/
function cleanupOldVersionsNamed(canonicalDir, binName, version) {
const prefix = `${binName}-`;
const currentVersioned = `${binName}-${version}`;
const entries = fs.readdirSync(canonicalDir);
const versionedBinaries = entries
.filter(e => {
if (!e.startsWith(prefix)) return false;
if (e.includes('.tmp.') || e.includes('.link.')) return false;
if (e === currentVersioned) return false;
const suffix = e.slice(prefix.length);
return /^\d/.test(suffix);
})
.sort((a, b) => {
const pa = a.slice(prefix.length).split('.').map(Number);
const pb = b.slice(prefix.length).split('.').map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] || 0) !== (pb[i] || 0)) return (pb[i] || 0) - (pa[i] || 0);
}
return 0;
});
for (const old of versionedBinaries.slice(1)) {
try { fs.unlinkSync(path.join(canonicalDir, old)); } catch {}
}
return versionedBinaries;
}
/** Install a named binary (mirrors postinstall.js installBinary). */
function installNamedBinary(vendoredBinPath, binName, version, canonicalDir) {
fs.mkdirSync(canonicalDir, { recursive: true });
const versionedName = `${binName}-${version}`;
const versionedPath = path.join(canonicalDir, versionedName);
const canonicalPath = path.join(canonicalDir, binName);
if (!fs.existsSync(versionedPath)) {
const tmpPath = versionedPath + `.tmp.${process.pid}`;
try {
fs.copyFileSync(vendoredBinPath, tmpPath);
fs.chmodSync(tmpPath, 0o755);
fs.renameSync(tmpPath, versionedPath);
} finally {
try { fs.unlinkSync(tmpPath); } catch {}
}
}
const tmpLink = canonicalPath + `.link.${process.pid}`;
try { fs.unlinkSync(tmpLink); } catch {}
fs.symlinkSync(versionedName, tmpLink);
fs.renameSync(tmpLink, canonicalPath);
return { canonicalPath, versionedPath, versionedName };
}
test('installing both grok and grok-pager creates independent symlinks', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored');
fs.writeFileSync(vendored, 'grok-binary');
const vendoredPager = path.join(dir, 'vendored-pager');
fs.writeFileSync(vendoredPager, 'pager-binary');
installNamedBinary(vendored, 'grok', '0.1.141', binDir);
installNamedBinary(vendoredPager, 'grok-pager', '0.1.141', binDir);
// Both symlinks exist and point to correct targets
assert.strictEqual(fs.readlinkSync(path.join(binDir, 'grok')), 'grok-0.1.141');
assert.strictEqual(fs.readlinkSync(path.join(binDir, 'grok-pager')), 'grok-pager-0.1.141');
// Both versioned files exist with correct content
assert.strictEqual(fs.readFileSync(path.join(binDir, 'grok-0.1.141'), 'utf8'), 'grok-binary');
assert.strictEqual(fs.readFileSync(path.join(binDir, 'grok-pager-0.1.141'), 'utf8'), 'pager-binary');
} finally {
cleanup(dir);
}
});
test('cleanup of grok-* does not remove grok-pager-*', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
// Old grok versions
fs.writeFileSync(path.join(binDir, 'grok-0.1.138'), 'old-grok-1');
fs.writeFileSync(path.join(binDir, 'grok-0.1.139'), 'old-grok-2');
fs.writeFileSync(path.join(binDir, 'grok-0.1.140'), 'old-grok-3');
// Current grok
fs.writeFileSync(path.join(binDir, 'grok-0.1.141'), 'current-grok');
// grok-pager versions (should not be touched)
fs.writeFileSync(path.join(binDir, 'grok-pager-0.1.138'), 'old-pager-1');
fs.writeFileSync(path.join(binDir, 'grok-pager-0.1.139'), 'old-pager-2');
fs.writeFileSync(path.join(binDir, 'grok-pager-0.1.141'), 'current-pager');
cleanupOldVersionsNamed(binDir, 'grok', '0.1.141');
// grok cleanup: current + N-1 kept, older removed
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.141')), 'current grok should exist');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.140')), 'N-1 grok should be kept');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-0.1.139')), 'N-2 grok should be removed');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-0.1.138')), 'N-3 grok should be removed');
// ALL grok-pager versions must be untouched
assert.ok(fs.existsSync(path.join(binDir, 'grok-pager-0.1.138')), 'grok-pager-0.1.138 must survive grok cleanup');
assert.ok(fs.existsSync(path.join(binDir, 'grok-pager-0.1.139')), 'grok-pager-0.1.139 must survive grok cleanup');
assert.ok(fs.existsSync(path.join(binDir, 'grok-pager-0.1.141')), 'grok-pager-0.1.141 must survive grok cleanup');
} finally {
cleanup(dir);
}
});
test('cleanup of grok-pager-* does not remove grok-*', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
// grok versions (should not be touched)
fs.writeFileSync(path.join(binDir, 'grok-0.1.138'), 'old-grok-1');
fs.writeFileSync(path.join(binDir, 'grok-0.1.139'), 'old-grok-2');
fs.writeFileSync(path.join(binDir, 'grok-0.1.141'), 'current-grok');
// Old grok-pager versions
fs.writeFileSync(path.join(binDir, 'grok-pager-0.1.138'), 'old-pager-1');
fs.writeFileSync(path.join(binDir, 'grok-pager-0.1.139'), 'old-pager-2');
fs.writeFileSync(path.join(binDir, 'grok-pager-0.1.140'), 'old-pager-3');
// Current pager
fs.writeFileSync(path.join(binDir, 'grok-pager-0.1.141'), 'current-pager');
cleanupOldVersionsNamed(binDir, 'grok-pager', '0.1.141');
// grok-pager cleanup: current + N-1 kept, older removed
assert.ok(fs.existsSync(path.join(binDir, 'grok-pager-0.1.141')), 'current pager should exist');
assert.ok(fs.existsSync(path.join(binDir, 'grok-pager-0.1.140')), 'N-1 pager should be kept');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-pager-0.1.139')), 'N-2 pager should be removed');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-pager-0.1.138')), 'N-3 pager should be removed');
// ALL grok versions must be untouched
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.138')), 'grok-0.1.138 must survive pager cleanup');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.139')), 'grok-0.1.139 must survive pager cleanup');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.141')), 'grok-0.1.141 must survive pager cleanup');
} finally {
cleanup(dir);
}
});
test('full dual-binary lifecycle: install, upgrade, cleanup both', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored');
const vendoredPager = path.join(dir, 'vendored-pager');
// v1
fs.writeFileSync(vendored, 'grok-v1');
fs.writeFileSync(vendoredPager, 'pager-v1');
installNamedBinary(vendored, 'grok', '0.1.140', binDir);
installNamedBinary(vendoredPager, 'grok-pager', '0.1.140', binDir);
// v2
fs.writeFileSync(vendored, 'grok-v2');
fs.writeFileSync(vendoredPager, 'pager-v2');
installNamedBinary(vendored, 'grok', '0.1.141', binDir);
installNamedBinary(vendoredPager, 'grok-pager', '0.1.141', binDir);
// v3
fs.writeFileSync(vendored, 'grok-v3');
fs.writeFileSync(vendoredPager, 'pager-v3');
installNamedBinary(vendored, 'grok', '0.1.142', binDir);
installNamedBinary(vendoredPager, 'grok-pager', '0.1.142', binDir);
// Cleanup both independently
cleanupOldVersionsNamed(binDir, 'grok', '0.1.142');
cleanupOldVersionsNamed(binDir, 'grok-pager', '0.1.142');
// Current + N-1 for each
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.142')));
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.141')));
assert.ok(!fs.existsSync(path.join(binDir, 'grok-0.1.140')));
assert.ok(fs.existsSync(path.join(binDir, 'grok-pager-0.1.142')));
assert.ok(fs.existsSync(path.join(binDir, 'grok-pager-0.1.141')));
assert.ok(!fs.existsSync(path.join(binDir, 'grok-pager-0.1.140')));
// Symlinks correct
assert.strictEqual(fs.readlinkSync(path.join(binDir, 'grok')), 'grok-0.1.142');
assert.strictEqual(fs.readlinkSync(path.join(binDir, 'grok-pager')), 'grok-pager-0.1.142');
} finally {
cleanup(dir);
}
});
// ═══════════════════════════════════════════════════════════════════════
// macOS-only Pager Platform Split Tests
// ═══════════════════════════════════════════════════════════════════════
console.log('\nmacOS-only pager platform split tests\n');
test('grok installs normally regardless of platform key', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored-grok');
fs.writeFileSync(vendored, 'grok-binary');
for (const platform of ['darwin-arm64', 'linux-x64', 'linux-arm64']) {
const result = installNamedBinary(vendored, 'grok', '0.1.150', binDir);
assert.ok(fs.existsSync(result.versionedPath), `grok should install for ${platform}`);
assert.strictEqual(fs.readlinkSync(result.canonicalPath), 'grok-0.1.150');
}
} finally {
cleanup(dir);
}
});
test('grok-pager installs when vendored binary exists (darwin-arm64 path)', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendoredPager = path.join(dir, 'vendored-pager');
fs.writeFileSync(vendoredPager, 'pager-binary');
const result = installNamedBinary(vendoredPager, 'grok-pager', '0.1.150', binDir);
assert.ok(fs.existsSync(result.versionedPath), 'pager versioned binary should exist');
assert.strictEqual(fs.readlinkSync(result.canonicalPath), 'grok-pager-0.1.150');
assert.strictEqual(fs.readFileSync(result.canonicalPath, 'utf8'), 'pager-binary');
} finally {
cleanup(dir);
}
});
test('Linux pager vendor files are not required for grok install', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendorBase = path.join(dir, 'vendor');
// Only darwin-arm64 pager exists (mirrors npm tarball)
fs.mkdirSync(path.join(vendorBase, 'darwin-arm64'), { recursive: true });
fs.writeFileSync(path.join(vendorBase, 'darwin-arm64', 'grok-pager'), 'mac-pager');
// Linux pager vendor dirs exist but without pager binaries
fs.mkdirSync(path.join(vendorBase, 'linux-x64'), { recursive: true });
fs.mkdirSync(path.join(vendorBase, 'linux-arm64'), { recursive: true });
// Verify no Linux pager binaries
assert.ok(!fs.existsSync(path.join(vendorBase, 'linux-x64', 'grok-pager')));
assert.ok(!fs.existsSync(path.join(vendorBase, 'linux-arm64', 'grok-pager')));
// grok install should succeed independently
const grokVendored = path.join(dir, 'vendored-grok');
fs.writeFileSync(grokVendored, 'grok-linux');
const result = installNamedBinary(grokVendored, 'grok', '0.1.150', binDir);
assert.ok(fs.existsSync(result.versionedPath), 'grok should install without Linux pager');
} finally {
cleanup(dir);
}
});
test('skipping pager install on Linux does not affect grok cleanup', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored');
// Install grok across two versions
fs.writeFileSync(vendored, 'grok-v1');
installNamedBinary(vendored, 'grok', '0.1.149', binDir);
fs.writeFileSync(vendored, 'grok-v2');
installNamedBinary(vendored, 'grok', '0.1.150', binDir);
// Simulate Linux: only run grok cleanup, skip pager entirely
cleanupOldVersionsNamed(binDir, 'grok', '0.1.150');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.150')), 'current grok should exist');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.149')), 'N-1 grok should be kept');
assert.strictEqual(fs.readlinkSync(path.join(binDir, 'grok')), 'grok-0.1.150');
// No pager files should exist at all
const entries = fs.readdirSync(binDir);
const pagerEntries = entries.filter(e => e.includes('pager'));
assert.strictEqual(pagerEntries.length, 0, 'no pager artifacts on Linux');
} finally {
cleanup(dir);
}
});
test('canonical pager from non-npm install is preserved on Linux', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
// Simulate pager installed by install-grok.sh (not npm)
const pagerVersioned = path.join(binDir, 'grok-pager-0.1.150');
fs.writeFileSync(pagerVersioned, 'installer-pager-binary');
fs.chmodSync(pagerVersioned, 0o755);
const pagerCanonical = path.join(binDir, 'grok-pager');
fs.symlinkSync('grok-pager-0.1.150', pagerCanonical);
// Run grok-only install + cleanup (simulating Linux postinstall)
const vendored = path.join(dir, 'vendored');
fs.writeFileSync(vendored, 'grok-binary');
installNamedBinary(vendored, 'grok', '0.1.150', binDir);
cleanupOldVersionsNamed(binDir, 'grok', '0.1.150');
// Pager installed by other means must be untouched
assert.ok(fs.existsSync(pagerCanonical), 'canonical pager should survive');
assert.ok(fs.existsSync(pagerVersioned), 'versioned pager should survive');
assert.strictEqual(fs.readlinkSync(pagerCanonical), 'grok-pager-0.1.150');
} finally {
cleanup(dir);
}
});
// ─── Summary ───────────────────────────────────────────────────────────
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
@@ -1,334 +0,0 @@
#
# Grok CLI installer (enterprise channel) for PowerShell — https://x.ai/cli/enterprise-install.ps1
#
# Standalone installer for the enterprise channel. Intentionally a full copy of
# the install logic so changes to the stable installer cannot break enterprise.
#
# Auth: KIGI_DEPLOYMENT_KEY env var (takes precedence) or ~/.grok/auth.json from `grok login`.
# Env: KIGI_BIN_DIR, KIGI_PROXY_URL
#
# Usage:
# irm https://x.ai/cli/enterprise-install.ps1 | iex # latest enterprise
# & ([scriptblock]::Create((irm https://x.ai/cli/enterprise-install.ps1))) -Version 0.1.42 # specific version
# $env:KIGI_VERSION="0.1.42"; irm https://x.ai/cli/enterprise-install.ps1 | iex # specific version (alt)
# $env:KIGI_DEPLOYMENT_KEY="<key>"; irm https://x.ai/cli/enterprise-install.ps1 | iex
#
param(
[Parameter(Position = 0)]
[string]$Version
)
$ErrorActionPreference = 'Stop'
# PS 5.1 defaults to TLS 1.0; GCS requires TLS 1.2.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
# PS 5.1's Invoke-WebRequest progress bar is extremely slow; disable it.
$ProgressPreference = 'SilentlyContinue'
# Accept version from environment variable (useful with irm | iex).
if (-not $Version -and $env:KIGI_VERSION) {
$Version = $env:KIGI_VERSION
}
# This script is Windows-only. PS 5.1 has no Platform property and only runs on Windows.
if ($PSVersionTable.Platform -and $PSVersionTable.Platform -ne 'Win32NT') {
Write-Error "This installer is for Windows. On macOS/Linux, use: curl -fsSL https://x.ai/cli/enterprise-install.sh | bash"
exit 1
}
$GrokDir = Join-Path $env:USERPROFILE '.grok'
# --- Helpers ---
function Download-String([string]$Url) {
try {
$response = Invoke-WebRequest -Uri $Url -UseBasicParsing
return $response.Content
} catch {
return $null
}
}
function Download-File([string]$Url, [string]$OutFile) {
# TODO: parallel byte-range download (matches install-enterprise.sh download_file_parallel).
# Skipped for now: requires Start-ThreadJob / RunspacePool for true parallelism on PS 5.1
# and HEAD + Range request orchestration. Single-connection HttpWebRequest below remains.
# Stream via HttpWebRequest — faster than Invoke-WebRequest on PS 5.1 and supports progress.
$request = [System.Net.HttpWebRequest]::Create($Url)
$request.Timeout = 300000 # 5 min
$request.AutomaticDecompression = [System.Net.DecompressionMethods]::GZip -bor [System.Net.DecompressionMethods]::Deflate
$response = $request.GetResponse()
$totalBytes = $response.ContentLength
$stream = $response.GetResponseStream()
$fileStream = [System.IO.File]::Create($OutFile)
$buffer = New-Object byte[] 65536
$totalRead = 0
$lastPercent = -1
$lastMb = -1
try {
while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) {
$fileStream.Write($buffer, 0, $read)
$totalRead += $read
$mb = [math]::Round($totalRead / 1MB, 1)
if ($totalBytes -gt 0) {
$percent = [math]::Min(100, [math]::Floor(($totalRead / $totalBytes) * 100))
if ($percent -ne $lastPercent) {
$totalMb = [math]::Round($totalBytes / 1MB, 1)
Write-Host "`r Downloading... ${mb} MB / ${totalMb} MB (${percent}%)" -NoNewline
$lastPercent = $percent
}
} elseif ($mb -ne $lastMb) {
Write-Host "`r Downloading... ${mb} MB" -NoNewline
$lastMb = $mb
}
}
Write-Host ''
} finally {
$fileStream.Close()
$stream.Close()
$response.Close()
}
}
function Read-GrokToken([string]$Scope) {
$authFile = Join-Path $GrokDir 'auth.json'
if (-not (Test-Path $authFile)) { return $null }
try {
$auth = Get-Content -Raw $authFile | ConvertFrom-Json
$entry = $auth.$Scope
if ($entry -and $entry.key) { return $entry.key }
} catch {}
return $null
}
# --- Validate version ---
if ($Version -and $Version -notmatch '^\d+\.\d+\.\d+(-\S+)?$') {
Write-Error "Invalid version format: $Version (expected X.Y.Z or X.Y.Z-suffix)"
exit 1
}
# --- Resolve auth ---
$OidcScope = 'https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828'
$LegacyScope = 'https://accounts.x.ai/sign-in'
$AuthSource = ''
if ($env:KIGI_DEPLOYMENT_KEY) {
$AuthSource = 'deployment key'
Write-Host 'Auth: using deployment key.' -ForegroundColor DarkGray
} else {
$oidcToken = Read-GrokToken $OidcScope
$legacyToken = Read-GrokToken $LegacyScope
if ($oidcToken) {
$AuthSource = 'auth.json (oidc)'
Write-Host 'Auth: using OIDC token from ~/.grok/auth.json.' -ForegroundColor DarkGray
} elseif ($legacyToken) {
$AuthSource = 'auth.json (legacy)'
Write-Host 'Auth: using legacy token from ~/.grok/auth.json.' -ForegroundColor DarkGray
}
}
# --- Detect architecture ---
$arch = switch ($env:PROCESSOR_ARCHITECTURE) {
'AMD64' { 'x86_64' }
'x86' { 'x86_64' } # 32-bit PS on 64-bit Windows
'ARM64' { 'aarch64' }
default { $null }
}
if (-not $arch) {
Write-Error "Unsupported architecture: $env:PROCESSOR_ARCHITECTURE"
exit 1
}
$platform = "windows-$arch"
# --- Resolve version ---
$BaseUrlPrimary = 'https://x.ai/cli'
$BaseUrlFallback = 'https://storage.googleapis.com/grok-build-public-artifacts/cli'
$DownloadDir = Join-Path $GrokDir 'downloads'
$BinDir = if ($env:KIGI_BIN_DIR) { $env:KIGI_BIN_DIR } else { Join-Path $GrokDir 'bin' }
New-Item -ItemType Directory -Path $DownloadDir -Force | Out-Null
New-Item -ItemType Directory -Path $BinDir -Force | Out-Null
$Channel = 'enterprise'
# Pick a working BaseUrl: try Cloudflare-fronted x.ai first, fall back to
# direct GCS if it's unreachable. The probe doubles as the channel-pointer
# fetch when no -Version was passed, so the happy path costs zero extra requests.
if (-not $Version) { Write-Host "Fetching latest $Channel version..." -ForegroundColor DarkGray }
$probeResult = Download-String "$BaseUrlPrimary/$Channel"
if ($probeResult) {
$BaseUrl = $BaseUrlPrimary
} else {
Write-Host "Note: $BaseUrlPrimary unreachable, falling back to direct GCS." -ForegroundColor Yellow
$BaseUrl = $BaseUrlFallback
$probeResult = Download-String "$BaseUrl/$Channel"
}
if ($Version) {
$resolvedVersion = $Version
} elseif ($probeResult) {
$resolvedVersion = $probeResult.Trim()
} else {
Write-Error "Failed to fetch latest version from $BaseUrlPrimary/$Channel and $BaseUrlFallback/$Channel"
exit 1
}
if ($AuthSource) {
Write-Host "Installing Grok $resolvedVersion ($platform, $AuthSource)..." -ForegroundColor Cyan
} else {
Write-Host "Installing Grok $resolvedVersion ($platform)..." -ForegroundColor Cyan
}
# --- Download binary ---
$binaryPath = Join-Path $DownloadDir "grok-$platform.exe"
$artifactBase = "$BaseUrl/grok-$resolvedVersion-$platform"
$downloaded = $false
foreach ($url in @("$artifactBase.exe", $artifactBase)) {
try {
Download-File $url $binaryPath
$downloaded = $true
break
} catch {
continue
}
}
if (-not $downloaded) {
if (Test-Path $binaryPath) { Remove-Item $binaryPath -Force }
Write-Error "Binary download failed from $artifactBase.exe and $artifactBase"
exit 1
}
# --- Install binary (locked-file safe) ---
foreach ($binName in @('grok.exe', 'agent.exe')) {
$dest = Join-Path $BinDir $binName
$old = "$dest.old"
if (Test-Path $old) { Remove-Item $old -Force -ErrorAction SilentlyContinue }
try {
Copy-Item -Path $binaryPath -Destination $dest -Force
} catch {
try {
if (Test-Path $dest) { Rename-Item $dest $old -Force -ErrorAction SilentlyContinue }
Copy-Item -Path $binaryPath -Destination $dest -Force
} catch {
if (Test-Path $old) { Rename-Item $old $dest -Force -ErrorAction SilentlyContinue }
Write-Error "Failed to install $binName"
exit 1
}
}
}
Write-Host " Installed to $BinDir\grok.exe and $BinDir\agent.exe." -ForegroundColor DarkGray
# --- Generate completions (best-effort) ---
$completionsDir = Join-Path (Join-Path $GrokDir 'completions') 'powershell'
try {
New-Item -ItemType Directory -Path $completionsDir -Force | Out-Null
& (Join-Path $BinDir 'grok.exe') completions powershell 2>$null |
Set-Content (Join-Path $completionsDir 'grok.ps1') -ErrorAction SilentlyContinue
} catch {}
# --- Persist installer config ---
$ConfigFile = Join-Path $GrokDir 'config.toml'
$cliLines = @('installer = "internal"', 'channel = "enterprise"')
if (-not (Test-Path $ConfigFile)) {
New-Item -ItemType Directory -Path (Split-Path $ConfigFile) -Force | Out-Null
$content = "[cli]`r`n" + ($cliLines -join "`r`n") + "`r`n"
[System.IO.File]::WriteAllText($ConfigFile, $content, [System.Text.Encoding]::UTF8)
} elseif ((Get-Content -Raw $ConfigFile) -match '(?m)^\[cli\]') {
# Section-aware: only replace installer/channel under [cli], not other sections.
$existingLines = Get-Content $ConfigFile
$output = [System.Collections.ArrayList]::new()
$inCli = $false
foreach ($line in $existingLines) {
if ($line -match '^\[cli\]\s*(#.*)?$') {
[void]$output.Add($line)
foreach ($cl in $cliLines) { [void]$output.Add($cl) }
$inCli = $true
continue
}
if ($line -match '^\[.+\]\s*(#.*)?$') {
$inCli = $false
}
if ($inCli -and $line -match '^\s*(installer|channel)\s*=') {
continue
}
[void]$output.Add($line)
}
[System.IO.File]::WriteAllLines($ConfigFile, [string[]]$output.ToArray(), [System.Text.Encoding]::UTF8)
} else {
Add-Content -Path $ConfigFile -Value "`r`n[cli]`r`n$($cliLines -join "`r`n")`r`n"
}
# --- Fetch deployment config (deployment key only) ---
if ($env:KIGI_DEPLOYMENT_KEY) {
$ProxyUrl = if ($env:KIGI_PROXY_URL) { $env:KIGI_PROXY_URL } else { 'https://cli-chat-proxy.grok.com/v1' }
Write-Host ' Fetching deployment config...' -ForegroundColor DarkGray
try {
$headers = @{ 'Authorization' = "Bearer $($env:KIGI_DEPLOYMENT_KEY)" }
$deployResponse = Invoke-RestMethod -Uri "$ProxyUrl/deployment/config" -Headers $headers -UseBasicParsing
} catch {
Write-Host " Warning: failed to fetch deployment config from $ProxyUrl/deployment/config" -ForegroundColor Yellow
$deployResponse = $null
}
if ($deployResponse) {
$managedConfig = $deployResponse.managed_config
$requirements = $deployResponse.requirements
$managedConfigPath = Join-Path $GrokDir 'managed_config.toml'
$requirementsPath = Join-Path $GrokDir 'requirements.toml'
if ($managedConfig -and $managedConfig -ne 'null') {
[System.IO.File]::WriteAllText($managedConfigPath, $managedConfig, [System.Text.Encoding]::UTF8)
Write-Host ' Managed config applied.' -ForegroundColor DarkGray
} else {
if (Test-Path $managedConfigPath) { Remove-Item $managedConfigPath -Force }
}
if ($requirements -and $requirements -ne 'null') {
[System.IO.File]::WriteAllText($requirementsPath, $requirements, [System.Text.Encoding]::UTF8)
Write-Host ' Requirements applied.' -ForegroundColor DarkGray
} else {
if (Test-Path $requirementsPath) { Remove-Item $requirementsPath -Force }
}
}
}
Write-Host "Grok $resolvedVersion installed to $BinDir\grok.exe" -ForegroundColor Green
# --- Ensure grok is on PATH ---
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
$pathEntries = if ($userPath) { $userPath -split ';' | Where-Object { $_ -ne '' } } else { @() }
if ($pathEntries -notcontains $BinDir) {
$newPath = (@($BinDir) + $pathEntries) -join ';'
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
Write-Host " Added $BinDir to your User PATH." -ForegroundColor DarkGray
# Update current session so grok works immediately.
if ($env:Path -notlike "*$BinDir*") {
$env:Path = "$BinDir;$env:Path"
}
}
Write-Host ''
Write-Host "Run 'grok' or 'agent' to get started!" -ForegroundColor Cyan
@@ -1,430 +0,0 @@
#!/bin/bash
#
# Grok CLI installer (enterprise channel) — https://x.ai/cli/enterprise-install.sh
#
# Standalone installer for the enterprise channel. This is intentionally a full
# copy of the install logic (not a wrapper around install.sh) so that changes to
# the stable installer cannot accidentally break enterprise deployments.
#
# Auth: KIGI_DEPLOYMENT_KEY (takes precedence) or ~/.grok/auth.json from `grok login`.
# Env: KIGI_BIN_DIR, KIGI_PROXY_URL
#
# Usage:
# curl -fsSL https://x.ai/cli/enterprise-install.sh | bash # latest enterprise
# curl -fsSL https://x.ai/cli/enterprise-install.sh | bash -s 0.1.42 # specific version
# KIGI_DEPLOYMENT_KEY=<key> bash <(curl -fsSL https://x.ai/cli/enterprise-install.sh)
#
# Windows: run under Git for Windows / MSYS2 Bash (same curl | bash flow); WSL
# uses the Linux binary.
set -e
TARGET="$1"
if [[ -n "$TARGET" ]] && [[ ! "$TARGET" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9._]+)?$ ]]; then
echo "Invalid version format: $TARGET (expected X.Y.Z or X.Y.Z-suffix)" >&2
exit 1
fi
DOWNLOADER=""
if command -v curl >/dev/null 2>&1; then
DOWNLOADER="curl"
elif command -v wget >/dev/null 2>&1; then
DOWNLOADER="wget"
else
echo "Either curl or wget is required but neither is installed" >&2
exit 1
fi
download_file() {
local url="$1" output="$2"
if [ "$DOWNLOADER" = "curl" ]; then
if [ -n "$output" ]; then
curl -fsSL -o "$output" "$url"
else
curl -fsSL "$url"
fi
else
if [ -n "$output" ]; then
wget -q -O "$output" "$url"
else
wget -q -O - "$url"
fi
fi
}
# Parallel byte-range download. Falls back to single-connection download_file
# whenever HEAD lacks Content-Length, the file is small (<16 MiB), curl is
# unavailable, or any chunk fetch / concat fails.
download_file_parallel() {
local url="$1" output="$2"
if [ "$DOWNLOADER" != "curl" ]; then
download_file "$url" "$output"
return
fi
local size
size=$(curl -fsSL --head "$url" 2>/dev/null | awk -F'[: \r\n]+' 'tolower($1)=="content-length"{print $2; exit}')
if [ -z "$size" ] || ! [ "$size" -ge 16777216 ] 2>/dev/null; then
download_file "$url" "$output"
return
fi
local n=8
local chunk_size=$(( (size + n - 1) / n ))
local tmpdir
tmpdir=$(mktemp -d 2>/dev/null) || { download_file "$url" "$output"; return; }
local pids=() i start end
for i in $(seq 0 $((n - 1))); do
start=$((i * chunk_size))
end=$((start + chunk_size - 1))
[ $end -ge $size ] && end=$((size - 1))
curl -fsSL -r "${start}-${end}" -o "${tmpdir}/$(printf 'chunk.%03d' "$i")" "$url" &
pids+=($!)
done
local all_ok=true pid
for pid in "${pids[@]}"; do
wait "$pid" || all_ok=false
done
if [ "$all_ok" = true ] && cat "${tmpdir}"/chunk.* > "$output" 2>/dev/null; then
rm -rf "$tmpdir"
return 0
fi
rm -rf "$tmpdir"
download_file "$url" "$output"
}
# Return 0 if a HEAD request for the URL gets HTTP 404.
is_not_found() {
local url="$1" code
if [ "$DOWNLOADER" = "curl" ]; then
code=$(curl -o /dev/null -sSL -w '%{http_code}' --head "$url" 2>/dev/null) || true
else
code=$(wget --server-response --spider "$url" 2>&1 | awk '/HTTP\//{print $2}' | tail -1) || true
fi
[ "$code" = "404" ]
}
# JSON field extractor — extract a top-level string value using sed.
json_get() {
local json="$1" field="$2"
# Extract value (handling \" inside strings), then unescape JSON sequences.
printf '%s' "$json" | sed -n -E 's/.*"'"$field"'"[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)".*/\1/p' | head -1 \
| sed -e 's/\\"/"/g' -e 's/\\n/\'$'\n''/g' -e 's/\\t/\'$'\t''/g' -e 's/\\\\/\\/g'
}
# Read a token from ~/.grok/auth.json for the given scope key.
# Format: {"scope_url": {"key": "token"}, ...}
read_grok_token() {
local auth_file="$HOME/.grok/auth.json"
local scope="$1"
[ -f "$auth_file" ] || return 1
# Flatten to one line then extract: find the scope, then the "key" value after it
tr -d '\n' < "$auth_file" | sed -n 's|.*"'"$scope"'"[[:space:]]*:[[:space:]]*{[^}]*"key"[[:space:]]*:[[:space:]]*"\([^"]*\)".*|\1|p' | head -1
}
# Resolve auth: KIGI_DEPLOYMENT_KEY > OIDC token > legacy token
OIDC_SCOPE="https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"
LEGACY_SCOPE="https://accounts.x.ai/sign-in"
AUTH_SOURCE=""
if [ -n "$KIGI_DEPLOYMENT_KEY" ]; then
AUTH_SOURCE="deployment key"
echo "Auth: using deployment key." >&2
else
OIDC_TOKEN=$(read_grok_token "$OIDC_SCOPE" 2>/dev/null) || true
LEGACY_TOKEN=$(read_grok_token "$LEGACY_SCOPE" 2>/dev/null) || true
if [ -n "$OIDC_TOKEN" ]; then
AUTH_SOURCE="auth.json (oidc)"
echo "Auth: using OIDC token from ~/.grok/auth.json." >&2
elif [ -n "$LEGACY_TOKEN" ]; then
AUTH_SOURCE="auth.json (legacy)"
echo "Auth: using legacy token from ~/.grok/auth.json." >&2
fi
fi
case "$(uname -s)" in
Darwin) os="macos" ;;
Linux) os="linux" ;;
# Git for Windows / MSYS2 / Cygwin host — native Windows builds
MINGW* | MSYS* | CYGWIN*) os="windows" ;;
*) echo "Unsupported OS: $(uname -s)" >&2; exit 1 ;;
esac
case "$(uname -m)" in
x86_64|amd64|AMD64) arch="x86_64" ;;
arm64|aarch64|ARM64) arch="aarch64" ;;
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac
BASE_URL_PRIMARY="https://x.ai/cli"
BASE_URL_FALLBACK="https://storage.googleapis.com/grok-build-public-artifacts/cli"
DOWNLOAD_DIR="$HOME/.grok/downloads"
BIN_DIR="${KIGI_BIN_DIR:-$HOME/.grok/bin}"
mkdir -p "$DOWNLOAD_DIR" "$BIN_DIR"
platform="${os}-${arch}"
CHANNEL="enterprise"
# Pick a working BASE_URL: try Cloudflare-fronted x.ai first, fall back to
# direct GCS if it's unreachable. The probe doubles as the channel-pointer
# fetch when no explicit TARGET was passed, so the happy path costs zero
# extra HTTP requests.
if [ -z "$TARGET" ]; then echo "Fetching latest ${CHANNEL} version..." >&2; fi
probe_result=$(download_file "${BASE_URL_PRIMARY}/${CHANNEL}" 2>/dev/null) || true
if [ -n "$probe_result" ]; then
BASE_URL="$BASE_URL_PRIMARY"
else
echo "Note: ${BASE_URL_PRIMARY} unreachable, falling back to direct GCS." >&2
BASE_URL="$BASE_URL_FALLBACK"
probe_result=$(download_file "${BASE_URL}/${CHANNEL}" 2>/dev/null) || true
fi
if [ -n "$TARGET" ]; then
version="$TARGET"
else
version=$(printf '%s' "$probe_result" | tr -d '\r' | head -n1 | tr -d '[:space:]')
if [ -z "$version" ]; then
echo "Error: failed to fetch latest version from ${BASE_URL_PRIMARY}/${CHANNEL} and ${BASE_URL_FALLBACK}/${CHANNEL}" >&2
exit 1
fi
fi
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9._]+)?$ ]]; then
echo "Invalid version format: $version (expected X.Y.Z or X.Y.Z-suffix)" >&2
exit 1
fi
if [ -n "$AUTH_SOURCE" ]; then
echo "Installing Grok $version ($platform, $AUTH_SOURCE)..." >&2
else
echo "Installing Grok $version ($platform)..." >&2
fi
binary_path="$DOWNLOAD_DIR/grok-$platform"
artifact_base="${BASE_URL}/grok-${version}-${platform}"
if [ "$os" = "windows" ]; then
binary_path="${binary_path}.exe"
fi
echo " Downloading grok ${version}..." >&2
if [ "$os" = "windows" ]; then
if ! download_file_parallel "${artifact_base}.exe" "$binary_path"; then
if ! download_file_parallel "$artifact_base" "$binary_path"; then
rm -f "$binary_path"
if is_not_found "${artifact_base}.exe"; then
echo "Error: Grok is not yet available for your system ($platform)." >&2
else
echo "Error: binary download failed (${artifact_base}.exe and ${artifact_base})" >&2
fi
exit 1
fi
fi
elif ! download_file_parallel "$artifact_base" "$binary_path"; then
rm -f "$binary_path"
if is_not_found "$artifact_base"; then
echo "Error: Grok is not yet available for your system ($platform)." >&2
else
echo "Error: binary download failed from ${artifact_base}" >&2
fi
exit 1
fi
if [ "$os" = "windows" ]; then
# Symlinks require Developer Mode on Windows; copy instead.
# If the exe is locked by a running process, rename it aside then retry.
for bin_name in grok.exe agent.exe; do
rm -f "$BIN_DIR/$bin_name.old" 2>/dev/null || true # stale backup from prior update
if ! cp -f "$binary_path" "$BIN_DIR/$bin_name" 2>/dev/null; then
mv -f "$BIN_DIR/$bin_name" "$BIN_DIR/$bin_name.old" 2>/dev/null || true
if ! cp -f "$binary_path" "$BIN_DIR/$bin_name" 2>/dev/null; then
# Rollback: restore the old binary so the install isn't broken.
mv -f "$BIN_DIR/$bin_name.old" "$BIN_DIR/$bin_name" 2>/dev/null || true
echo "Error: failed to install $bin_name" >&2
exit 1
fi
fi
done
echo " Binary installed to $BIN_DIR/grok.exe and $BIN_DIR/agent.exe." >&2
else
chmod +x "$binary_path"
ln -sf "$binary_path" "$BIN_DIR/grok"
ln -sf "$binary_path" "$BIN_DIR/agent"
echo " Binary linked to $BIN_DIR/grok and $BIN_DIR/agent." >&2
fi
# Generate shell completions (best-effort)
mkdir -p "$HOME/.grok/completions/bash" "$HOME/.grok/completions/zsh"
"$BIN_DIR/grok" completions bash > "$HOME/.grok/completions/bash/grok.bash" 2>/dev/null || true
"$BIN_DIR/grok" completions zsh > "$HOME/.grok/completions/zsh/_grok" 2>/dev/null || true
# Fish: write to the auto-loaded completions dir so it works immediately
if mkdir -p "$HOME/.config/fish/completions" 2>/dev/null; then
"$BIN_DIR/grok" completions fish > "$HOME/.config/fish/completions/grok.fish" 2>/dev/null || true
fi
# Persist installer source and channel to config
CONFIG_FILE="$HOME/.grok/config.toml"
CLI_BLOCK="installer = \"internal\"\nchannel = \"enterprise\""
if [ ! -f "$CONFIG_FILE" ]; then
printf '[cli]\n%b\n' "$CLI_BLOCK" > "$CONFIG_FILE"
elif grep -q '^\[cli\]' "$CONFIG_FILE"; then
tmp="$CONFIG_FILE.tmp.$$"
awk -v block="$CLI_BLOCK" '
/^\[cli\][[:space:]]*(#.*)?$/ { print; printf "%s\n", block; in_cli=1; next }
/^\[.*\][[:space:]]*(#.*)?$/ { in_cli=0 }
in_cli && /^[[:space:]]*(installer|channel)[[:space:]]*=/ { next }
{ print }
' "$CONFIG_FILE" > "$tmp" && mv "$tmp" "$CONFIG_FILE"
else
printf '\n[cli]\n%b\n' "$CLI_BLOCK" >> "$CONFIG_FILE"
fi
# Fetch managed_config.toml + requirements.toml from server (deployment key only).
if [ -n "$KIGI_DEPLOYMENT_KEY" ]; then
PROXY_URL="${KIGI_PROXY_URL:-https://cli-chat-proxy.grok.com/v1}"
echo " Fetching deployment config..." >&2
DEPLOY_RESPONSE=""
AUTH_HEADER_FILE=$(mktemp 2>/dev/null) || AUTH_HEADER_FILE=""
if [ -n "$AUTH_HEADER_FILE" ]; then
chmod 600 "$AUTH_HEADER_FILE" 2>/dev/null || true
printf 'Authorization: Bearer %s\n' "$KIGI_DEPLOYMENT_KEY" > "$AUTH_HEADER_FILE"
DEPLOY_RESPONSE=$(curl -sS -f \
-H "@${AUTH_HEADER_FILE}" \
"${PROXY_URL}/deployment/config" 2>/dev/null) || DEPLOY_RESPONSE=""
: > "$AUTH_HEADER_FILE" 2>/dev/null || true
rm -f "$AUTH_HEADER_FILE"
fi
if [ -z "$DEPLOY_RESPONSE" ]; then
echo " Warning: failed to fetch deployment config from ${PROXY_URL}/deployment/config" >&2
fi
if [ -n "$DEPLOY_RESPONSE" ]; then
MANAGED_CONFIG=$(json_get "$DEPLOY_RESPONSE" "managed_config")
REQUIREMENTS=$(json_get "$DEPLOY_RESPONSE" "requirements")
if [ -n "$MANAGED_CONFIG" ] && [ "$MANAGED_CONFIG" != "null" ]; then
printf '%s\n' "$MANAGED_CONFIG" > "$HOME/.grok/managed_config.toml"
echo " Managed config applied." >&2
else
rm -f "$HOME/.grok/managed_config.toml"
fi
if [ -n "$REQUIREMENTS" ] && [ "$REQUIREMENTS" != "null" ]; then
printf '%s\n' "$REQUIREMENTS" > "$HOME/.grok/requirements.toml"
echo " Requirements applied." >&2
else
rm -f "$HOME/.grok/requirements.toml"
fi
fi
fi
if [ "$os" = "windows" ]; then
echo "Grok $version installed to $BIN_DIR/grok.exe" >&2
else
echo "Grok $version installed to $BIN_DIR/grok" >&2
fi
# --- Ensure grok is on PATH ---
path_has_dir() {
case ":$PATH:" in *":$1:"*) return 0 ;; *) return 1 ;; esac
}
# Try to symlink into a directory already on PATH so grok works immediately
# without restarting the shell. Candidate dirs in preference order.
SYMLINK_CREATED=""
if [ "$os" != "windows" ] && ! path_has_dir "$BIN_DIR"; then
for candidate in "$HOME/.local/bin" "/usr/local/bin"; do
if path_has_dir "$candidate" && [ -d "$candidate" ] && [ -w "$candidate" ]; then
ln -sf "$BIN_DIR/grok" "$candidate/grok"
ln -sf "$BIN_DIR/agent" "$candidate/agent"
SYMLINK_CREATED="$candidate"
echo " Symlinked $candidate/grok -> $BIN_DIR/grok" >&2
echo " Symlinked $candidate/agent -> $BIN_DIR/agent" >&2
break
fi
done
fi
# Also update shell config so ~/.grok/bin is on PATH for future sessions
user_shell="$(basename "${SHELL:-}")"
config_file=""
case "$user_shell" in
bash) config_file="$HOME/.bashrc" ;;
zsh) config_file="$HOME/.zshrc" ;;
fish) config_file="$HOME/.config/fish/config.fish" ;;
esac
if [ -n "$config_file" ]; then
mkdir -p "$(dirname "$config_file")"
# Resolve symlinks so tmp+mv rewrites the stow/dotfiles target, not the link.
if [ -e "$config_file" ] || [ -L "$config_file" ]; then
_cf="$config_file"
_depth=0
while [ -L "$_cf" ] && [ "$_depth" -lt 40 ]; do
_link="$(readlink "$_cf")" || break
case "$_link" in
/*) _cf="$_link" ;;
*) _cf="$(cd "$(dirname "$_cf")" && pwd -P)/$_link" ;;
esac
_depth=$((_depth + 1))
done
# Still a symlink (cycle/cap): leave original path so we never rewrite the link.
if [ ! -L "$_cf" ]; then
config_file="$(cd "$(dirname "$_cf")" && pwd -P)/$(basename "$_cf")"
fi
unset _cf _link _depth
fi
# Build the new installer block
if [ "$user_shell" = "fish" ]; then
new_block='# >>> grok installer >>>
fish_add_path $HOME/.grok/bin
# <<< grok installer <<<'
elif [ "$user_shell" = "zsh" ]; then
new_block='# >>> grok installer >>>
export PATH="$HOME/.grok/bin:$PATH"
fpath=(~/.grok/completions/zsh $fpath)
autoload -Uz compinit && compinit -C
# <<< grok installer <<<'
else
new_block='# >>> grok installer >>>
export PATH="$HOME/.grok/bin:$PATH"
[[ -r "$HOME/.grok/completions/bash/grok.bash" ]] && source "$HOME/.grok/completions/bash/grok.bash"
# <<< grok installer <<<'
fi
if grep -qs "grok installer" "$config_file" 2>/dev/null; then
# Replace existing block in-place (strip old >>> to <<< lines, insert new)
tmp="$config_file.tmp.$$"
awk '
/# >>> grok installer >>>/ { skip=1; next }
/# <<< grok installer <<</ { skip=0; next }
!skip { print }
' "$config_file" > "$tmp" && mv "$tmp" "$config_file"
else
[ -f "$config_file" ] && cp "$config_file" "$config_file.bak.$(date +%s)"
# macOS bash: ensure bash_profile sources bashrc
if [ "$user_shell" = "bash" ] && [ "$(uname -s)" = "Darwin" ]; then
if [ -f "$HOME/.bash_profile" ] && ! grep -qs "source ~/.bashrc" "$HOME/.bash_profile"; then
printf '\n[[ -r ~/.bashrc ]] && source ~/.bashrc\n' >> "$HOME/.bash_profile"
fi
fi
fi
printf '\n%s\n' "$new_block" >> "$config_file"
echo " Updated $BIN_DIR in PATH in $config_file." >&2
fi
echo "" >&2
if path_has_dir "$BIN_DIR" || [ -n "$SYMLINK_CREATED" ]; then
echo "Run 'grok' or 'agent' to get started!" >&2
elif [ -n "$config_file" ]; then
echo "Restart your terminal, then run 'grok' or 'agent' to get started!" >&2
else
echo "Add $BIN_DIR to your PATH, then run 'grok' or 'agent' to get started:" >&2
echo ' export PATH="$HOME/.grok/bin:$PATH"' >&2
fi
if [ "$os" = "windows" ]; then
echo "To use grok from cmd.exe or PowerShell, add %USERPROFILE%\\.grok\\bin to your PATH." >&2
fi
-334
View File
@@ -1,334 +0,0 @@
#
# Grok CLI installer for PowerShell — https://x.ai/cli/install.ps1
#
# Auth: KIGI_DEPLOYMENT_KEY env var (takes precedence) or ~/.grok/auth.json from `grok login`.
# Env: KIGI_CHANNEL (stable|alpha|enterprise, default: stable), KIGI_BIN_DIR, KIGI_PROXY_URL
#
# Usage:
# irm https://x.ai/cli/install.ps1 | iex # latest stable
# & ([scriptblock]::Create((irm https://x.ai/cli/install.ps1))) -Version 0.1.42 # specific version
# $env:KIGI_VERSION="0.1.42"; irm https://x.ai/cli/install.ps1 | iex # specific version (alt)
# $env:KIGI_DEPLOYMENT_KEY="<key>"; irm https://x.ai/cli/install.ps1 | iex
#
param(
[Parameter(Position = 0)]
[string]$Version
)
$ErrorActionPreference = 'Stop'
# PS 5.1 defaults to TLS 1.0; GCS requires TLS 1.2.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
# PS 5.1's Invoke-WebRequest progress bar is extremely slow; disable it.
$ProgressPreference = 'SilentlyContinue'
# Accept version from environment variable (useful with irm | iex).
if (-not $Version -and $env:KIGI_VERSION) {
$Version = $env:KIGI_VERSION
}
# This script is Windows-only. PS 5.1 has no Platform property and only runs on Windows.
if ($PSVersionTable.Platform -and $PSVersionTable.Platform -ne 'Win32NT') {
Write-Error "This installer is for Windows. On macOS/Linux, use: curl -fsSL https://x.ai/cli/install.sh | bash"
exit 1
}
$GrokDir = Join-Path $env:USERPROFILE '.grok'
# --- Helpers ---
function Download-String([string]$Url) {
try {
$response = Invoke-WebRequest -Uri $Url -UseBasicParsing
return $response.Content
} catch {
return $null
}
}
function Download-File([string]$Url, [string]$OutFile) {
# TODO: parallel byte-range download (matches install.sh download_file_parallel).
# Skipped for now: requires Start-ThreadJob / RunspacePool for true parallelism on PS 5.1
# and HEAD + Range request orchestration. Single-connection HttpWebRequest below remains.
# Stream via HttpWebRequest — faster than Invoke-WebRequest on PS 5.1 and supports progress.
$request = [System.Net.HttpWebRequest]::Create($Url)
$request.Timeout = 300000 # 5 min
$request.AutomaticDecompression = [System.Net.DecompressionMethods]::GZip -bor [System.Net.DecompressionMethods]::Deflate
$response = $request.GetResponse()
$totalBytes = $response.ContentLength
$stream = $response.GetResponseStream()
$fileStream = [System.IO.File]::Create($OutFile)
$buffer = New-Object byte[] 65536
$totalRead = 0
$lastPercent = -1
$lastMb = -1
try {
while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) {
$fileStream.Write($buffer, 0, $read)
$totalRead += $read
$mb = [math]::Round($totalRead / 1MB, 1)
if ($totalBytes -gt 0) {
$percent = [math]::Min(100, [math]::Floor(($totalRead / $totalBytes) * 100))
if ($percent -ne $lastPercent) {
$totalMb = [math]::Round($totalBytes / 1MB, 1)
Write-Host "`r Downloading... ${mb} MB / ${totalMb} MB (${percent}%)" -NoNewline
$lastPercent = $percent
}
} elseif ($mb -ne $lastMb) {
Write-Host "`r Downloading... ${mb} MB" -NoNewline
$lastMb = $mb
}
}
Write-Host ''
} finally {
$fileStream.Close()
$stream.Close()
$response.Close()
}
}
function Read-GrokToken([string]$Scope) {
$authFile = Join-Path $GrokDir 'auth.json'
if (-not (Test-Path $authFile)) { return $null }
try {
$auth = Get-Content -Raw $authFile | ConvertFrom-Json
$entry = $auth.$Scope
if ($entry -and $entry.key) { return $entry.key }
} catch {}
return $null
}
# --- Validate version ---
if ($Version -and $Version -notmatch '^\d+\.\d+\.\d+(-\S+)?$') {
Write-Error "Invalid version format: $Version (expected X.Y.Z or X.Y.Z-suffix)"
exit 1
}
# --- Resolve auth ---
$OidcScope = 'https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828'
$LegacyScope = 'https://accounts.x.ai/sign-in'
$AuthSource = ''
if ($env:KIGI_DEPLOYMENT_KEY) {
$AuthSource = 'deployment key'
Write-Host 'Auth: using deployment key.' -ForegroundColor DarkGray
} else {
$oidcToken = Read-GrokToken $OidcScope
$legacyToken = Read-GrokToken $LegacyScope
if ($oidcToken) {
$AuthSource = 'auth.json (oidc)'
Write-Host 'Auth: using OIDC token from ~/.grok/auth.json.' -ForegroundColor DarkGray
} elseif ($legacyToken) {
$AuthSource = 'auth.json (legacy)'
Write-Host 'Auth: using legacy token from ~/.grok/auth.json.' -ForegroundColor DarkGray
}
}
# --- Detect architecture ---
$arch = switch ($env:PROCESSOR_ARCHITECTURE) {
'AMD64' { 'x86_64' }
'x86' { 'x86_64' } # 32-bit PS on 64-bit Windows
'ARM64' { 'aarch64' }
default { $null }
}
if (-not $arch) {
Write-Error "Unsupported architecture: $env:PROCESSOR_ARCHITECTURE"
exit 1
}
$platform = "windows-$arch"
# --- Resolve version and channel ---
$BaseUrlPrimary = 'https://x.ai/cli'
$BaseUrlFallback = 'https://storage.googleapis.com/grok-build-public-artifacts/cli'
$DownloadDir = Join-Path $GrokDir 'downloads'
$BinDir = if ($env:KIGI_BIN_DIR) { $env:KIGI_BIN_DIR } else { Join-Path $GrokDir 'bin' }
New-Item -ItemType Directory -Path $DownloadDir -Force | Out-Null
New-Item -ItemType Directory -Path $BinDir -Force | Out-Null
$Channel = if ($env:KIGI_CHANNEL) { $env:KIGI_CHANNEL } else { 'stable' }
# Pick a working BaseUrl: try Cloudflare-fronted x.ai first, fall back to
# direct GCS if it's unreachable. The probe doubles as the channel-pointer
# fetch when no -Version was passed, so the happy path costs zero extra requests.
if (-not $Version) { Write-Host "Fetching latest $Channel version..." -ForegroundColor DarkGray }
$probeResult = Download-String "$BaseUrlPrimary/$Channel"
if ($probeResult) {
$BaseUrl = $BaseUrlPrimary
} else {
Write-Host "Note: $BaseUrlPrimary unreachable, falling back to direct GCS." -ForegroundColor Yellow
$BaseUrl = $BaseUrlFallback
$probeResult = Download-String "$BaseUrl/$Channel"
}
if ($Version) {
$resolvedVersion = $Version
} elseif ($probeResult) {
$resolvedVersion = $probeResult.Trim()
} else {
Write-Error "Failed to fetch latest version from $BaseUrlPrimary/$Channel and $BaseUrlFallback/$Channel"
exit 1
}
if ($AuthSource) {
Write-Host "Installing Grok $resolvedVersion ($platform, $AuthSource)..." -ForegroundColor Cyan
} else {
Write-Host "Installing Grok $resolvedVersion ($platform)..." -ForegroundColor Cyan
}
# --- Download binary ---
$binaryPath = Join-Path $DownloadDir "grok-$platform.exe"
$artifactBase = "$BaseUrl/grok-$resolvedVersion-$platform"
$downloaded = $false
foreach ($url in @("$artifactBase.exe", $artifactBase)) {
try {
Download-File $url $binaryPath
$downloaded = $true
break
} catch {
continue
}
}
if (-not $downloaded) {
if (Test-Path $binaryPath) { Remove-Item $binaryPath -Force }
Write-Error "Binary download failed from $artifactBase.exe and $artifactBase"
exit 1
}
# --- Install binary (locked-file safe) ---
foreach ($binName in @('grok.exe', 'agent.exe')) {
$dest = Join-Path $BinDir $binName
$old = "$dest.old"
if (Test-Path $old) { Remove-Item $old -Force -ErrorAction SilentlyContinue }
try {
Copy-Item -Path $binaryPath -Destination $dest -Force
} catch {
try {
if (Test-Path $dest) { Rename-Item $dest $old -Force -ErrorAction SilentlyContinue }
Copy-Item -Path $binaryPath -Destination $dest -Force
} catch {
if (Test-Path $old) { Rename-Item $old $dest -Force -ErrorAction SilentlyContinue }
Write-Error "Failed to install $binName"
exit 1
}
}
}
Write-Host " Installed to $BinDir\grok.exe and $BinDir\agent.exe." -ForegroundColor DarkGray
# --- Generate completions (best-effort) ---
$completionsDir = Join-Path (Join-Path $GrokDir 'completions') 'powershell'
try {
New-Item -ItemType Directory -Path $completionsDir -Force | Out-Null
& (Join-Path $BinDir 'grok.exe') completions powershell 2>$null |
Set-Content (Join-Path $completionsDir 'grok.ps1') -ErrorAction SilentlyContinue
} catch {}
# --- Persist installer config ---
$ConfigFile = Join-Path $GrokDir 'config.toml'
$cliLines = @('installer = "internal"')
if ($Channel -ne 'stable') {
$cliLines += "channel = `"$Channel`""
}
if (-not (Test-Path $ConfigFile)) {
New-Item -ItemType Directory -Path (Split-Path $ConfigFile) -Force | Out-Null
$content = "[cli]`r`n" + ($cliLines -join "`r`n") + "`r`n"
[System.IO.File]::WriteAllText($ConfigFile, $content, [System.Text.Encoding]::UTF8)
} elseif ((Get-Content -Raw $ConfigFile) -match '(?m)^\[cli\]') {
# Section-aware: only replace installer/channel under [cli], not other sections.
$existingLines = Get-Content $ConfigFile
$output = [System.Collections.ArrayList]::new()
$inCli = $false
foreach ($line in $existingLines) {
if ($line -match '^\[cli\]\s*(#.*)?$') {
[void]$output.Add($line)
foreach ($cl in $cliLines) { [void]$output.Add($cl) }
$inCli = $true
continue
}
if ($line -match '^\[.+\]\s*(#.*)?$') {
$inCli = $false
}
if ($inCli -and $line -match '^\s*(installer|channel)\s*=') {
continue
}
[void]$output.Add($line)
}
[System.IO.File]::WriteAllLines($ConfigFile, [string[]]$output.ToArray(), [System.Text.Encoding]::UTF8)
} else {
Add-Content -Path $ConfigFile -Value "`r`n[cli]`r`n$($cliLines -join "`r`n")`r`n"
}
# --- Fetch deployment config (deployment key only) ---
if ($env:KIGI_DEPLOYMENT_KEY) {
$ProxyUrl = if ($env:KIGI_PROXY_URL) { $env:KIGI_PROXY_URL } else { 'https://cli-chat-proxy.grok.com/v1' }
Write-Host ' Fetching deployment config...' -ForegroundColor DarkGray
try {
$headers = @{ 'Authorization' = "Bearer $($env:KIGI_DEPLOYMENT_KEY)" }
$deployResponse = Invoke-RestMethod -Uri "$ProxyUrl/deployment/config" -Headers $headers -UseBasicParsing
} catch {
Write-Host " Warning: failed to fetch deployment config from $ProxyUrl/deployment/config" -ForegroundColor Yellow
$deployResponse = $null
}
if ($deployResponse) {
$managedConfig = $deployResponse.managed_config
$requirements = $deployResponse.requirements
$managedConfigPath = Join-Path $GrokDir 'managed_config.toml'
$requirementsPath = Join-Path $GrokDir 'requirements.toml'
if ($managedConfig -and $managedConfig -ne 'null') {
[System.IO.File]::WriteAllText($managedConfigPath, $managedConfig, [System.Text.Encoding]::UTF8)
Write-Host ' Managed config applied.' -ForegroundColor DarkGray
} else {
if (Test-Path $managedConfigPath) { Remove-Item $managedConfigPath -Force }
}
if ($requirements -and $requirements -ne 'null') {
[System.IO.File]::WriteAllText($requirementsPath, $requirements, [System.Text.Encoding]::UTF8)
Write-Host ' Requirements applied.' -ForegroundColor DarkGray
} else {
if (Test-Path $requirementsPath) { Remove-Item $requirementsPath -Force }
}
}
}
Write-Host "Grok $resolvedVersion installed to $BinDir\grok.exe" -ForegroundColor Green
# --- Ensure grok is on PATH ---
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
$pathEntries = if ($userPath) { $userPath -split ';' | Where-Object { $_ -ne '' } } else { @() }
if ($pathEntries -notcontains $BinDir) {
$newPath = (@($BinDir) + $pathEntries) -join ';'
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
Write-Host " Added $BinDir to your User PATH." -ForegroundColor DarkGray
# Update current session so grok works immediately.
if ($env:Path -notlike "*$BinDir*") {
$env:Path = "$BinDir;$env:Path"
}
}
Write-Host ''
Write-Host "Run 'grok' or 'agent' to get started!" -ForegroundColor Cyan
-447
View File
@@ -1,447 +0,0 @@
#!/bin/bash
#
# Grok CLI installer — https://x.ai/cli/install.sh
#
# Auth: KIGI_DEPLOYMENT_KEY (takes precedence) or ~/.grok/auth.json from `grok login`.
# Env: KIGI_CHANNEL (stable|alpha|enterprise, default: stable), KIGI_BIN_DIR, KIGI_PROXY_URL
#
# Usage:
# curl -fsSL https://x.ai/cli/install.sh | bash # latest stable
# curl -fsSL https://x.ai/cli/install.sh | bash -s 0.1.42 # specific version
# KIGI_DEPLOYMENT_KEY=<key> bash <(curl -fsSL https://x.ai/cli/install.sh)
#
# Windows: run under Git for Windows / MSYS2 Bash (same curl | bash flow); WSL
# uses the Linux binary.
set -e
TARGET="$1"
if [[ -n "$TARGET" ]] && [[ ! "$TARGET" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9._]+)?$ ]]; then
echo "Invalid version format: $TARGET (expected X.Y.Z or X.Y.Z-suffix)" >&2
exit 1
fi
DOWNLOADER=""
if command -v curl >/dev/null 2>&1; then
DOWNLOADER="curl"
elif command -v wget >/dev/null 2>&1; then
DOWNLOADER="wget"
else
echo "Either curl or wget is required but neither is installed" >&2
exit 1
fi
download_file() {
local url="$1" output="$2"
if [ "$DOWNLOADER" = "curl" ]; then
if [ -n "$output" ]; then
curl -fsSL -o "$output" "$url"
else
curl -fsSL "$url"
fi
else
if [ -n "$output" ]; then
wget -q -O "$output" "$url"
else
wget -q -O - "$url"
fi
fi
}
# Parallel byte-range download. Falls back to single-connection download_file
# whenever HEAD lacks Content-Length, the file is small (<16 MiB), curl is
# unavailable, or any chunk fetch / concat fails.
download_file_parallel() {
local url="$1" output="$2"
if [ "$DOWNLOADER" != "curl" ]; then
download_file "$url" "$output"
return
fi
local size
size=$(curl -fsSL --head "$url" 2>/dev/null | awk -F'[: \r\n]+' 'tolower($1)=="content-length"{print $2; exit}')
if [ -z "$size" ] || ! [ "$size" -ge 16777216 ] 2>/dev/null; then
download_file "$url" "$output"
return
fi
local n=8
local chunk_size=$(( (size + n - 1) / n ))
local tmpdir
tmpdir=$(mktemp -d 2>/dev/null) || { download_file "$url" "$output"; return; }
local pids=() i start end
for i in $(seq 0 $((n - 1))); do
start=$((i * chunk_size))
end=$((start + chunk_size - 1))
[ $end -ge $size ] && end=$((size - 1))
curl -fsSL -r "${start}-${end}" -o "${tmpdir}/$(printf 'chunk.%03d' "$i")" "$url" &
pids+=($!)
done
local all_ok=true pid
for pid in "${pids[@]}"; do
wait "$pid" || all_ok=false
done
if [ "$all_ok" = true ] && cat "${tmpdir}"/chunk.* > "$output" 2>/dev/null; then
rm -rf "$tmpdir"
return 0
fi
rm -rf "$tmpdir"
download_file "$url" "$output"
}
# Return 0 if a HEAD request for the URL gets HTTP 404.
is_not_found() {
local url="$1" code
if [ "$DOWNLOADER" = "curl" ]; then
code=$(curl -o /dev/null -sSL -w '%{http_code}' --head "$url" 2>/dev/null) || true
else
code=$(wget --server-response --spider "$url" 2>&1 | awk '/HTTP\//{print $2}' | tail -1) || true
fi
[ "$code" = "404" ]
}
# JSON field extractor — extract a top-level string value using sed.
json_get() {
local json="$1" field="$2"
# Extract value (handling \" inside strings), then unescape JSON sequences.
printf '%s' "$json" | sed -n -E 's/.*"'"$field"'"[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)".*/\1/p' | head -1 \
| sed -e 's/\\"/"/g' -e 's/\\n/\'$'\n''/g' -e 's/\\t/\'$'\t''/g' -e 's/\\\\/\\/g'
}
# Read a token from ~/.grok/auth.json for the given scope key.
# Format: {"scope_url": {"key": "token"}, ...}
read_grok_token() {
local auth_file="$HOME/.grok/auth.json"
local scope="$1"
[ -f "$auth_file" ] || return 1
# Flatten to one line then extract: find the scope, then the "key" value after it
tr -d '\n' < "$auth_file" | sed -n 's|.*"'"$scope"'"[[:space:]]*:[[:space:]]*{[^}]*"key"[[:space:]]*:[[:space:]]*"\([^"]*\)".*|\1|p' | head -1
}
# Resolve auth: KIGI_DEPLOYMENT_KEY > OIDC token > legacy token
OIDC_SCOPE="https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"
LEGACY_SCOPE="https://accounts.x.ai/sign-in"
AUTH_SOURCE=""
if [ -n "$KIGI_DEPLOYMENT_KEY" ]; then
AUTH_SOURCE="deployment key"
echo "Auth: using deployment key." >&2
else
OIDC_TOKEN=$(read_grok_token "$OIDC_SCOPE" 2>/dev/null) || true
LEGACY_TOKEN=$(read_grok_token "$LEGACY_SCOPE" 2>/dev/null) || true
if [ -n "$OIDC_TOKEN" ]; then
AUTH_SOURCE="auth.json (oidc)"
echo "Auth: using OIDC token from ~/.grok/auth.json." >&2
elif [ -n "$LEGACY_TOKEN" ]; then
AUTH_SOURCE="auth.json (legacy)"
echo "Auth: using legacy token from ~/.grok/auth.json." >&2
fi
fi
case "$(uname -s)" in
Darwin) os="macos" ;;
Linux) os="linux" ;;
# Git for Windows / MSYS2 / Cygwin host — native Windows builds
MINGW* | MSYS* | CYGWIN*) os="windows" ;;
*) echo "Unsupported OS: $(uname -s)" >&2; exit 1 ;;
esac
case "$(uname -m)" in
x86_64|amd64|AMD64) arch="x86_64" ;;
arm64|aarch64|ARM64) arch="aarch64" ;;
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac
BASE_URL_PRIMARY="https://x.ai/cli"
BASE_URL_FALLBACK="https://storage.googleapis.com/grok-build-public-artifacts/cli"
DOWNLOAD_DIR="$HOME/.grok/downloads"
BIN_DIR="${KIGI_BIN_DIR:-$HOME/.grok/bin}"
mkdir -p "$DOWNLOAD_DIR" "$BIN_DIR"
platform="${os}-${arch}"
CHANNEL="${KIGI_CHANNEL:-stable}"
# Pick a working BASE_URL: try Cloudflare-fronted x.ai first, fall back to
# direct GCS if it's unreachable. The probe doubles as the channel-pointer
# fetch when no explicit TARGET was passed, so the happy path costs zero
# extra HTTP requests.
if [ -z "$TARGET" ]; then echo "Fetching latest ${CHANNEL} version..." >&2; fi
probe_result=$(download_file "${BASE_URL_PRIMARY}/${CHANNEL}" 2>/dev/null) || true
if [ -n "$probe_result" ]; then
BASE_URL="$BASE_URL_PRIMARY"
else
echo "Note: ${BASE_URL_PRIMARY} unreachable, falling back to direct GCS." >&2
BASE_URL="$BASE_URL_FALLBACK"
probe_result=$(download_file "${BASE_URL}/${CHANNEL}" 2>/dev/null) || true
fi
if [ -n "$TARGET" ]; then
version="$TARGET"
else
version=$(printf '%s' "$probe_result" | tr -d '\r' | head -n1 | tr -d '[:space:]')
if [ -z "$version" ]; then
echo "Error: failed to fetch latest version from ${BASE_URL_PRIMARY}/${CHANNEL} and ${BASE_URL_FALLBACK}/${CHANNEL}" >&2
exit 1
fi
fi
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9._]+)?$ ]]; then
echo "Invalid version format: $version (expected X.Y.Z or X.Y.Z-suffix)" >&2
exit 1
fi
if [ -n "$AUTH_SOURCE" ]; then
echo "Installing Grok $version ($platform, $AUTH_SOURCE)..." >&2
else
echo "Installing Grok $version ($platform)..." >&2
fi
binary_path="$DOWNLOAD_DIR/grok-$platform"
artifact_base="${BASE_URL}/grok-${version}-${platform}"
if [ "$os" = "windows" ]; then
binary_path="${binary_path}.exe"
fi
binary_tmp="${binary_path}.tmp.$$"
rm -f "$binary_tmp" 2>/dev/null || true
echo " Downloading grok ${version}..." >&2
if [ "$os" = "windows" ]; then
if ! download_file_parallel "${artifact_base}.exe" "$binary_tmp"; then
if ! download_file_parallel "$artifact_base" "$binary_tmp"; then
rm -f "$binary_tmp"
if is_not_found "${artifact_base}.exe"; then
echo "Error: Grok is not yet available for your system ($platform)." >&2
else
echo "Error: binary download failed (${artifact_base}.exe and ${artifact_base})" >&2
fi
exit 1
fi
fi
elif ! download_file_parallel "$artifact_base" "$binary_tmp"; then
rm -f "$binary_tmp"
if is_not_found "$artifact_base"; then
echo "Error: Grok is not yet available for your system ($platform)." >&2
else
echo "Error: binary download failed from ${artifact_base}" >&2
fi
exit 1
fi
if [ "$os" = "windows" ]; then
mv -f "$binary_tmp" "$binary_path"
# Symlinks require Developer Mode on Windows; copy instead.
# If the exe is locked by a running process, rename it aside then retry.
for bin_name in grok.exe agent.exe; do
rm -f "$BIN_DIR/$bin_name.old" 2>/dev/null || true # stale backup from prior update
if ! cp -f "$binary_path" "$BIN_DIR/$bin_name" 2>/dev/null; then
mv -f "$BIN_DIR/$bin_name" "$BIN_DIR/$bin_name.old" 2>/dev/null || true
if ! cp -f "$binary_path" "$BIN_DIR/$bin_name" 2>/dev/null; then
# Rollback: restore the old binary so the install isn't broken.
mv -f "$BIN_DIR/$bin_name.old" "$BIN_DIR/$bin_name" 2>/dev/null || true
echo "Error: failed to install $bin_name" >&2
exit 1
fi
fi
done
echo " Binary installed to $BIN_DIR/grok.exe and $BIN_DIR/agent.exe." >&2
else
chmod +x "$binary_tmp"
if ! "$binary_tmp" --version </dev/null >/dev/null 2>&1; then
echo "Error: downloaded grok failed to run; keeping the existing install." >&2
rm -f "$binary_tmp"
exit 1
fi
mv -f "$binary_tmp" "$binary_path"
# Use relative symlinks when BIN_DIR and DOWNLOAD_DIR share a parent
# (default layout: ~/.grok/bin and ~/.grok/downloads are siblings).
# Relative symlinks survive Docker bind-mounts with a different $HOME.
if [ "$(dirname "$BIN_DIR")" = "$(dirname "$DOWNLOAD_DIR")" ]; then
link_target="../$(basename "$DOWNLOAD_DIR")/$(basename "$binary_path")"
else
link_target="$binary_path"
fi
ln -sf "$link_target" "$BIN_DIR/grok"
ln -sf "$link_target" "$BIN_DIR/agent"
echo " Binary linked to $BIN_DIR/grok and $BIN_DIR/agent." >&2
fi
# Generate shell completions (best-effort)
mkdir -p "$HOME/.grok/completions/bash" "$HOME/.grok/completions/zsh"
"$BIN_DIR/grok" completions bash > "$HOME/.grok/completions/bash/grok.bash" 2>/dev/null || true
"$BIN_DIR/grok" completions zsh > "$HOME/.grok/completions/zsh/_grok" 2>/dev/null || true
# Fish: write to the auto-loaded completions dir so it works immediately
if mkdir -p "$HOME/.config/fish/completions" 2>/dev/null; then
"$BIN_DIR/grok" completions fish > "$HOME/.config/fish/completions/grok.fish" 2>/dev/null || true
fi
# Persist installer source and channel to config
CONFIG_FILE="$HOME/.grok/config.toml"
CLI_BLOCK="installer = \"internal\""
if [ "$CHANNEL" != "stable" ]; then
CLI_BLOCK="${CLI_BLOCK}\nchannel = \"${CHANNEL}\""
fi
if [ ! -f "$CONFIG_FILE" ]; then
printf '[cli]\n%b\n' "$CLI_BLOCK" > "$CONFIG_FILE"
elif grep -q '^\[cli\]' "$CONFIG_FILE"; then
tmp="$CONFIG_FILE.tmp.$$"
awk -v block="$CLI_BLOCK" '
/^\[cli\][[:space:]]*(#.*)?$/ { print; printf "%s\n", block; in_cli=1; next }
/^\[.*\][[:space:]]*(#.*)?$/ { in_cli=0 }
in_cli && /^[[:space:]]*(installer|channel)[[:space:]]*=/ { next }
{ print }
' "$CONFIG_FILE" > "$tmp" && mv "$tmp" "$CONFIG_FILE"
else
printf '\n[cli]\n%b\n' "$CLI_BLOCK" >> "$CONFIG_FILE"
fi
# Fetch managed_config.toml + requirements.toml from server (deployment key only).
if [ -n "$KIGI_DEPLOYMENT_KEY" ]; then
PROXY_URL="${KIGI_PROXY_URL:-https://cli-chat-proxy.grok.com/v1}"
echo " Fetching deployment config..." >&2
DEPLOY_RESPONSE=""
AUTH_HEADER_FILE=$(mktemp 2>/dev/null) || AUTH_HEADER_FILE=""
if [ -n "$AUTH_HEADER_FILE" ]; then
chmod 600 "$AUTH_HEADER_FILE" 2>/dev/null || true
printf 'Authorization: Bearer %s\n' "$KIGI_DEPLOYMENT_KEY" > "$AUTH_HEADER_FILE"
DEPLOY_RESPONSE=$(curl -sS -f \
-H "@${AUTH_HEADER_FILE}" \
"${PROXY_URL}/deployment/config" 2>/dev/null) || DEPLOY_RESPONSE=""
: > "$AUTH_HEADER_FILE" 2>/dev/null || true
rm -f "$AUTH_HEADER_FILE"
fi
if [ -z "$DEPLOY_RESPONSE" ]; then
echo " Warning: failed to fetch deployment config from ${PROXY_URL}/deployment/config" >&2
fi
if [ -n "$DEPLOY_RESPONSE" ]; then
MANAGED_CONFIG=$(json_get "$DEPLOY_RESPONSE" "managed_config")
REQUIREMENTS=$(json_get "$DEPLOY_RESPONSE" "requirements")
if [ -n "$MANAGED_CONFIG" ] && [ "$MANAGED_CONFIG" != "null" ]; then
printf '%s\n' "$MANAGED_CONFIG" > "$HOME/.grok/managed_config.toml"
echo " Managed config applied." >&2
else
rm -f "$HOME/.grok/managed_config.toml"
fi
if [ -n "$REQUIREMENTS" ] && [ "$REQUIREMENTS" != "null" ]; then
printf '%s\n' "$REQUIREMENTS" > "$HOME/.grok/requirements.toml"
echo " Requirements applied." >&2
else
rm -f "$HOME/.grok/requirements.toml"
fi
fi
fi
if [ "$os" = "windows" ]; then
echo "Grok $version installed to $BIN_DIR/grok.exe" >&2
else
echo "Grok $version installed to $BIN_DIR/grok" >&2
fi
# --- Ensure grok is on PATH ---
path_has_dir() {
case ":$PATH:" in *":$1:"*) return 0 ;; *) return 1 ;; esac
}
# Try to symlink into a directory already on PATH so grok works immediately
# without restarting the shell. Candidate dirs in preference order.
SYMLINK_CREATED=""
if [ "$os" != "windows" ] && ! path_has_dir "$BIN_DIR"; then
for candidate in "$HOME/.local/bin" "/usr/local/bin"; do
if path_has_dir "$candidate" && [ -d "$candidate" ] && [ -w "$candidate" ]; then
ln -sf "$BIN_DIR/grok" "$candidate/grok"
ln -sf "$BIN_DIR/agent" "$candidate/agent"
SYMLINK_CREATED="$candidate"
echo " Symlinked $candidate/grok -> $BIN_DIR/grok" >&2
echo " Symlinked $candidate/agent -> $BIN_DIR/agent" >&2
break
fi
done
fi
# Also update shell config so ~/.grok/bin is on PATH for future sessions
user_shell="$(basename "${SHELL:-}")"
config_file=""
case "$user_shell" in
bash) config_file="$HOME/.bashrc" ;;
zsh) config_file="$HOME/.zshrc" ;;
fish) config_file="$HOME/.config/fish/config.fish" ;;
esac
if [ -n "$config_file" ]; then
mkdir -p "$(dirname "$config_file")"
# Resolve symlinks so tmp+mv rewrites the stow/dotfiles target, not the link.
if [ -e "$config_file" ] || [ -L "$config_file" ]; then
_cf="$config_file"
_depth=0
while [ -L "$_cf" ] && [ "$_depth" -lt 40 ]; do
_link="$(readlink "$_cf")" || break
case "$_link" in
/*) _cf="$_link" ;;
*) _cf="$(cd "$(dirname "$_cf")" && pwd -P)/$_link" ;;
esac
_depth=$((_depth + 1))
done
# Still a symlink (cycle/cap): leave original path so we never rewrite the link.
if [ ! -L "$_cf" ]; then
config_file="$(cd "$(dirname "$_cf")" && pwd -P)/$(basename "$_cf")"
fi
unset _cf _link _depth
fi
# Build the new installer block
if [ "$user_shell" = "fish" ]; then
new_block='# >>> grok installer >>>
fish_add_path $HOME/.grok/bin
# <<< grok installer <<<'
elif [ "$user_shell" = "zsh" ]; then
new_block='# >>> grok installer >>>
export PATH="$HOME/.grok/bin:$PATH"
fpath=(~/.grok/completions/zsh $fpath)
autoload -Uz compinit && compinit -C
# <<< grok installer <<<'
else
new_block='# >>> grok installer >>>
export PATH="$HOME/.grok/bin:$PATH"
[[ -r "$HOME/.grok/completions/bash/grok.bash" ]] && source "$HOME/.grok/completions/bash/grok.bash"
# <<< grok installer <<<'
fi
if grep -qs "grok installer" "$config_file" 2>/dev/null; then
# Replace existing block in-place (strip old >>> to <<< lines, insert new)
tmp="$config_file.tmp.$$"
awk '
/# >>> grok installer >>>/ { skip=1; next }
/# <<< grok installer <<</ { skip=0; next }
!skip { print }
' "$config_file" > "$tmp" && mv "$tmp" "$config_file"
else
[ -f "$config_file" ] && cp "$config_file" "$config_file.bak.$(date +%s)"
# macOS bash: ensure bash_profile sources bashrc
if [ "$user_shell" = "bash" ] && [ "$(uname -s)" = "Darwin" ]; then
if [ -f "$HOME/.bash_profile" ] && ! grep -qs "source ~/.bashrc" "$HOME/.bash_profile"; then
printf '\n[[ -r ~/.bashrc ]] && source ~/.bashrc\n' >> "$HOME/.bash_profile"
fi
fi
fi
printf '\n%s\n' "$new_block" >> "$config_file"
echo " Updated $BIN_DIR in PATH in $config_file." >&2
fi
echo "" >&2
if path_has_dir "$BIN_DIR" || [ -n "$SYMLINK_CREATED" ]; then
echo "Run 'grok' or 'agent' to get started!" >&2
elif [ -n "$config_file" ]; then
echo "Restart your terminal, then run 'grok' or 'agent' to get started!" >&2
else
echo "Add $BIN_DIR to your PATH, then run 'grok' or 'agent' to get started:" >&2
echo ' export PATH="$HOME/.grok/bin:$PATH"' >&2
fi
if [ "$os" = "windows" ]; then
echo "To use grok from cmd.exe or PowerShell, add %USERPROFILE%\\.grok\\bin to your PATH." >&2
fi