goke is a zero-dependency, type-safe CLI framework for TypeScript. CAC replacement with Standard Schema support (Zod, Valibot, ArkType). Use goke when building CLI tools — it handles commands, subcommands, options, type coercion, help generation, and more. Schema-based options give you automatic type inference, coercion from strings, and help text generation. Pair with `@goke/mcp` to generate CLI commands automatically from an MCP server. ALWAYS read this skill when a repo uses goke for its CLI.
44 stars
0 votes
0 copies
0 views
Added November 7, 2026
toolstypescriptgoshellbashsqlnodetestinggitapi
Works with
claude code
cursor
terminal
cli
api
mcp
Security analysis
A92/100
mediumUses curl or wget to download content
mediumInstalls packages at runtime which could introduce malicious dependencies
Installs into .claude/skills of the current project.
Are you the author of Goke?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/remorses-goke)
---
name: goke
description: >
goke is a zero-dependency, type-safe CLI framework for TypeScript. CAC replacement
with Standard Schema support (Zod, Valibot, ArkType). Use goke when building CLI
tools — it handles commands, subcommands, options, type coercion, help generation,
and more. Schema-based options give you automatic type inference, coercion from
strings, and help text generation. Pair with `@goke/mcp` to generate CLI
commands automatically from an MCP server. ALWAYS read this skill when a repo
uses goke for its CLI.
version: 0.0.1
---
# goke
Fetch the full README from GitHub and read it before using goke:
```bash
curl -L https://raw.githubusercontent.com/remorses/goke/main/README.md
```
> Read the README in full every time you use goke.
>
> Important: never use `head` or `tail` to truncate it. Read the full README instead.
## Install
```bash
npm install goke # or bun, pnpm, etc
```
## Quick Notes
- Core APIs: `cli.option`, `cli.use`, `cli.section`, `cli.version`, `cli.help`, `cli.completions`, `cli.parse`
- Prefer injected `{ fs, console, process }` over globals
- Use relative paths with injected `fs`; if a helper needs current-cwd semantics, pass injected `process.cwd` into that helper
- For JustBash compatibility tests, import the existing CLI from app code instead of defining a new CLI inside the test
- **Never install `picocolors`, `chalk`, `kleur`, or any color library.** Use `import { colors } from 'goke'` instead. It's a vendored picocolors with zero extra dependencies.
- Use [`@goke/mcp`](https://github.com/remorses/goke/tree/main/mcp) to generate CLI commands from an MCP server. `addMcpCommands({ cli, getMcpUrl, loadCache, saveCache })` discovers tools and registers a typed command for each one.
The README is the source of truth for rules, examples, testing patterns, JustBash integration, and API details.
## Reporting goke bugs
If you find a bug in goke, **always open a GitHub issue with the `gh` CLI**. Do not only mention it in chat.
```bash
gh issue create --repo remorses/goke --title "Short bug title" --body "$(cat <<'EOF'
**Model:** grok-4.6
**Harness:** OpenCode / Kimaki
**What happened**
Describe the unexpected behavior.
**Relevant goke code**
Paste the CLI definition or goke API usage from this package that triggered the bug.
**Reproduction**
Minimal example that fails:
```ts
import { goke } from 'goke'
const cli = goke('mycli')
cli.command('deploy', 'Deploy').action(() => {})
cli.parse()
```
EOF
)"
```
Always include in the issue body:
- the **model** used (for example `grok-4.6`, `claude-opus-4-6`)
- the **harness** (for example OpenCode, Claude Code, Cursor, Codex)
- the **relevant goke code** from this package that hit the bug
- a **minimal reproduction** example
If the README or this skill mentions a `goke` export that is missing from the installed package, upgrade `goke` to latest first before adding workarounds or custom local detection code:
```bash
pnpm update goke --latest
```
Use the project package manager for the repo you are editing. After upgrading, re-check the export from the installed package and continue with the documented API.
## Terminal Colors
**Never install a separate color library.** goke vendors picocolors and exports it as `colors`:
```ts
import { colors } from 'goke'
console.log(colors.green('success'))
console.log(colors.red('error'))
console.log(colors.bold(colors.cyan('info')))
```
Available formatters: `bold`, `dim`, `italic`, `underline`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `gray`, `bgRed`, `bgGreen`, etc. Color support is auto-detected.
## Agent Detection
goke exports `isAgent`, `agent`, `agentInfo`, and `detectAgent()` from `goke/src/agents.ts`. Use `isAgent` to detect if the CLI is running inside an AI coding agent and skip interactive prompts or prefer structured output.
```ts
import { isAgent, agent } from 'goke'
if (isAgent) {
// skip clack prompts, output YAML/JSON instead of interactive UI
}
```
`isAgent` is for **skipping interactive prompts that have a CLI flag alternative** (e.g. `--env staging` instead of a clack select). Use it to require the flag and fail with a usage hint instead of hanging on stdin:
```ts
if (!env) {
if (isAgent || !process.stdin.isTTY) {
console.error('Missing --env. Usage: deploy --env staging|production')
process.exit(1)
}
// ... clack prompt fallback
}
```
**Never use `isAgent` to block commands that have no flag alternative**, like device-flow login. Those commands don't read stdin at all; they open a browser, print a code, and poll. The only real requirement is a TTY for spinner output. Use `!process.stdin.isTTY` or `!process.stdout.isTTY` alone for those:
```ts
// login command — no flag alternative, agent can run it in tuistory
if (!process.stdin.isTTY) {
console.error('Login requires a terminal. Run in tuistory or tmux.')
process.exit(1)
}
```
Agents can run login commands in a PTY via tuistory and complete the browser flow with playwriter. Blocking them with `isAgent` makes login impossible from agent contexts.
Supported agents: `cursor`, `claude`, `devin`, `replit`, `gemini`, `codex`, `auggie`, `opencode`, `kiro`, `goose`, `pi`. Set `AI_AGENT` env var to override.
## Long-Running Interactive Commands
Commands that start a browser/device login flow need to work for both interactive users and AI agents. goke's built-in **daemon** pattern (`ctx.daemon`) handles this: the command forks itself into a background process that waits for browser approval, while the foreground returns immediately so agents aren't blocked.
Use `isAgent` to branch between agent mode (start daemon, return immediately) and interactive mode (block until login completes). Do not fail in non-TTY shells; use the daemon instead.
```ts
import { goke, isAgent, openInBrowser } from 'goke'
cli
.command('login', 'Authenticate with browser login')
.action(async (options, ctx) => {
if (ctx.daemon.isDaemon) {
// ── DAEMON: create the URL, hand it to the foreground, then wait ──
const flow = await startOAuthFlow({ /* ... */ })
ctx.daemon.publishStartupMessage(`Authorize: ${flow.authorizationUrl}`)
ctx.daemon.ready()
const result = await flow.waitForApproval()
if (result.success) saveAuth(result)
return // daemon exits, PID file is cleaned up
}
// ── CLIENT: decide foreground vs background ──
if (isAgent) {
// Agent mode: return after the daemon publishes its authorization URL
await ctx.daemon.start({
waitForStartup: true,
timeoutMs: 10 * 60 * 1000,
})
ctx.console.log('Login running in background.')
ctx.console.log('After approving in browser, verify with: mycli me')
return
}
// Interactive mode: attach to daemon, see all its logs and errors in real time
await ctx.daemon.start({ attach: true, timeoutMs: 10 * 60 * 1000 })
ctx.console.log('Login successful!')
})
```
Use `waitForStartup: true` when the daemon creates an OAuth URL, device code, or other value that the agent must see before the foreground command returns. Publish ordered messages with `publishStartupMessage(message, { stream: 'stdout' | 'stderr' })`, then call `ready()`. Detached stdio remains ignored. Attached mode still receives the messages directly and waits for the daemon to exit.
Add a `me` command (exits 0 if logged in, 1 if not) so agents can poll for completion. Use `ctx.daemon.forCommand('login')` to check the login daemon status from other commands. See the **Background Daemons** section in the goke README for the full pattern, including `env` passthrough and PID file safety.
## Shell Completions
**Always add `.completions()` next to `.help()` in every CLI.** This gives users Tab completion for free.
```ts
cli.help()
cli.completions()
cli.parse()
```
### How it works
The shell calls the CLI binary on every Tab press with a hidden `--get-goke-completions` flag. The binary inspects registered commands and options, prints matching candidates to stdout, and exits. No static completion file to regenerate when commands change.
### README section for new CLIs
Every new CLI should include a **Shell Completions** section in its README. Add it after the main usage docs:
````md
## Shell Completions
Enable Tab completion for your shell:
```bash
mycli completions install
```
Restart your shell (or run `autoload -Uz compinit && compinit` for zsh). Then Tab works:
```bash
mycli <TAB> # shows all commands
mycli dep<TAB> # completes to "deploy"
mycli deploy --<TAB> # shows available options
```
Completions stay up-to-date automatically. To remove:
```bash
mycli completions uninstall
```
````
Replace `mycli` with the actual CLI name.
### Available completions commands
`.completions()` registers three subcommands:
- `completions install` — finds a writable shell completion directory and writes the shim script
- `completions uninstall` — removes installed completion files
- `completions script` — prints the raw script to stdout (for `eval` or piping)
All three accept `--shell zsh` or `--shell bash` to override auto-detection.
## openInBrowser is async
`openInBrowser` returns a `Promise<void>` and **must be awaited**. Without `await`, the process may exit before the browser opens. In non-TTY environments it writes the URL to stderr, keeping stdout clean for JSON parsing.
```ts
import { openInBrowser } from 'goke'
await openInBrowser('https://example.com/dashboard')
```
## Command Descriptions and Examples
**Use backtick formatting in descriptions** for flags and command references (e.g. `` `--status` ``, `` `mycli deploy` ``). Plain text flag names won't render as code in generated docs.
**Use `.example()` for usage examples, not the description string.** `generateDocs()` auto-wraps `.example()` strings in fenced `` ```sh `` code blocks. Examples in the description render as plain text without syntax highlighting.
```ts
cli.command(
'query <sql>',
dedent`
Run a SQL query. Add \`--json\` for the raw JSON envelope.
`,
)
.example('mycli query "SELECT * FROM users" -p my-app')
.example('mycli query "SELECT * FROM users FORMAT CSV" > out.csv')
```
## Command Naming Conventions
**ALWAYS read existing commands before adding a new one.** Scan the CLI for option names, verbs, and noun patterns already in use. New commands must stay consistent with what exists.
### Consistent option names
Options that do the same thing across different commands must use the same name. For example, if `list` commands already use `--limit` to cap results, never introduce `--max` or `--count` for the same purpose in a new command. Grep the codebase for similar options before picking a name.
### CRUD-style spaced commands
Prefer spaced subcommands that read like `noun verb`:
```
project list
project add
project remove
```
Pick **singular or plural** for the noun and stick with it across the entire CLI. If `project list` exists, don't add `projects add`.
**Always group namespaced commands with `.section()`.** Commands that share a parent word (`get pods`, `get services`, `auth login`) must sit under a named heading in root help. Call `cli.section('Get')` before registering that group. Use `.command(...).section('Name')` when one command belongs in a different group.
```ts
cli.section('Get')
cli.command('get pods', 'List pods')
cli.command('get services', 'List services')
cli.section('Describe')
cli.command('describe pod <name>', 'Describe a pod')
```
### Consistent verbs
Choose one verb per action and reuse it everywhere:
| Action | Pick one | Not both |
|--------|----------|----------|
| Create | `add` or `create` | not both |
| Delete | `remove` or `delete` | not both |
| Show | `show` or `get` | not both |
| Update | `update` or `edit` | not both |
Check which verbs the CLI already uses and match them. If existing commands use `add`, every new "create something" command should also use `add`.
### Never mix different ID kinds in positionals
All positionals of a command must identify the same kind of thing. Never give the first positional a different meaning than the rest (e.g. `events <recordingId> [...eventIds]`): a call like `events 1 4 7` is unreadable because the first number silently refers to a different entity. Move the parent/scoping ID to an optional flag with a sensible default and keep positionals homogeneous:
```ts
cli
.command('recorder events [...eventIds]', 'Print recorded events')
.option('-r, --recording <id>', 'Recording ID (defaults to the latest recording)')
```
## Prefer Optional Flags Over Required Flags
**Never make a flag required when it can be optional with an interactive fallback.** Required flags force users to read `--help` before they can run anything. Optional flags let them run the bare command and discover options progressively through prompts.
The pattern: make the flag optional, and when the user omits it, show a `clack.select` prompt in TTY mode or exit with a clear error in non-TTY mode.
```ts
cli
.command('deploy', 'Deploy the app')
.option(
'--env [env]',
z.enum(['staging', 'production']).optional().describe('Target environment'),
)
.action(async (options) => {
let env = options.env
if (!env) {
if (!process.stdin.isTTY) {
console.error('Missing --env. Usage: deploy --env staging|production')
process.exit(1)
}
const choice = await clack.select({
message: 'Which environment?',
options: [
{ value: 'staging', label: 'Staging' },
{ value: 'production', label: 'Production', hint: 'requires approval' },
],
})
if (clack.isCancel(choice)) {
process.exit(0)
}
env = choice
}
// env is now guaranteed to be defined
})
```
This applies to every flag that has a finite set of valid values. If you can enumerate the choices, make it a select prompt. The non-TTY error message must show the exact flag name and valid values so agents and CI scripts can self-correct.
**Bad** — forces users to know the flag upfront:
```ts
.option('--env <env>', z.enum(['staging', 'production']).describe('Target environment'))
```
**Good** — users can just run `deploy` and get prompted:
```ts
.option('--env [env]', z.enum(['staging', 'production']).optional().describe('Target environment'))
```
For flags with free-form string values (not enums), use `clack.text` instead of `clack.select`:
```ts
if (!options.name) {
if (!process.stdin.isTTY) {
console.error('Missing --name. Usage: create --name "my-project"')
process.exit(1)
}
const name = await clack.text({ message: 'Project name' })
if (clack.isCancel(name)) process.exit(0)
options.name = name
}
```
## Error Messages with Recovery Hints
**Every error message should include a hint showing how to fix or rerun the command.** When a CLI exits with an error, the user (or agent) should be able to copy-paste a corrected command immediately instead of reading `--help`.
```ts
console.error('Failed to detect local port.')
console.error('If your server prints the port in an unusual format, specify it explicitly:')
console.error(` mycli serve -p <port> -- ${shellQuote(args)}`)
process.exit(1)
```
Include the user's original arguments in the suggested command when possible, so the hint is directly copy-pasteable. For timeouts, show how long it waited so the user knows whether to wait longer or try a different approach.
## Interactive Prompts with @clack/prompts
Use `@clack/prompts` for interactive CLI prompts like `select`, `confirm`, and text input.
```bash
npm install @clack/prompts
```
```ts
import * as clack from '@clack/prompts'
const method = await clack.select({
message: 'Choose authentication method',
options: [
{ value: 'google', label: 'Google', hint: 'opens browser for OAuth' },
{ value: 'imap', label: 'Other', hint: 'IMAP/SMTP with password' },
],
})
if (clack.isCancel(method)) {
process.exit(0)
}
const confirmed = await clack.confirm({
message: 'Delete this item?',
initialValue: false,
})
if (clack.isCancel(confirmed) || !confirmed) {
process.exit(0)
}
```
Always guard clack prompts with `process.stdin.isTTY`. Agents and CI often run with non-TTY stdin, so interactive prompts must fall back to explicit CLI options instead of hanging.
### Select prompts
When a command shows a `select` prompt in TTY mode, always add a matching CLI option so agents can pass the choice directly.
```ts
cli
.command('login', 'Authenticate')
.option(
'--method <method>',
z.enum(['google', 'imap']).optional().describe('Authentication method'),
)
.action(async (options) => {
let method = options.method
if (!method) {
if (!process.stdin.isTTY) {
console.error('Run non-interactively with: zele login --method google|imap')
process.exit(1)
}
const choice = await clack.select({
message: 'Choose authentication method',
options: [
{ value: 'google', label: 'Google', hint: 'opens browser for OAuth' },
{ value: 'imap', label: 'Other', hint: 'IMAP/SMTP with password' },
],
})
if (clack.isCancel(choice)) {
process.exit(0)
}
method = choice
}
if (method === 'imap') {
return
}
})
```
### Confirm prompts
For destructive confirmations, add a `--force` flag and exit with a clear error in non-TTY mode when it is missing.
```ts
cli
.command('delete <id>', 'Delete an item')
.option('--force', 'Skip confirmation')
.action(async (id, options) => {
if (!options.force) {
if (!process.stdin.isTTY) {
console.error('Use --force to delete non-interactively')
process.exit(1)
}
const confirmed = await clack.confirm({
message: `Delete ${id}?`,
initialValue: false,
})
if (clack.isCancel(confirmed) || !confirmed) {
return
}
}
})
```
## Remote Server Auth & Config
CLIs that talk to a remote server must support multiple server URLs so users can self-host, use a preview/staging environment, or point to localhost during development. Auth tokens and other per-server state live in a JSON config file keyed by API URL.
**Never log config file paths** (like `~/.myapp/config.json` or `~/.myapp/auth.json`) in CLI output. Agents read CLI output and will try to `cat` or parse these files directly, bypassing the CLI's own commands. Log success/failure messages without revealing internal storage paths.
### Config file location
Store config at `~/.cliname/config.json`. The directory is named after the CLI binary. Use `os.homedir()` to resolve `~`.
### Config structure
The config is an object keyed by server URL. Each entry holds auth tokens and any other per-server state. The CLI reads/writes only the entry matching the current `--api-url`.
```ts
// ~/.cliname/config.json
{
"https://api.cliname.com": {
"accessToken": "tok_abc123",
"refreshToken": "rt_xyz789",
"expiresAt": "2026-08-01T00:00:00Z"
},
"https://staging.cliname.com": {
"accessToken": "tok_staging_456"
},
"http://localhost:3000": {
"accessToken": "tok_dev_789"
}
}
```
### Global `--api-url` option
Register `--api-url` as a global option with a default pointing to the production hosted service. The `.use()` middleware resolves the final URL from the flag, env var, or default, then **writes it back to `process.env`**. All other code just reads `process.env.CLINAME_API_URL` instead of threading `options.apiUrl` through every function call. This avoids type-safety issues since global options aren't visible in command action types.
```ts
import { goke } from 'goke'
import { z } from 'zod'
const DEFAULT_API_URL = 'https://api.cliname.com'
const cli = goke('cliname')
cli.option(
'--api-url [url]',
z.string().url().optional().describe('Server URL'),
)
cli.use((options) => {
const apiUrl = (
options.apiUrl
|| process.env.CLINAME_API_URL
|| DEFAULT_API_URL
).replace(/\/+$/, '') // normalize: strip trailing slash so config keys are consistent
process.env.CLINAME_API_URL = apiUrl
})
```
After this middleware runs, any module can call `getApiUrl()` without receiving it as a parameter:
```ts
export function getApiUrl(): string {
return process.env.CLINAME_API_URL!
}
```
### Reading and writing config
Config helpers take the injected `fs` from the action context as an object argument. This keeps them portable across normal Node.js runs and JustBash sandboxes.
```ts
import path from 'node:path'
import os from 'node:os'
import type { GokeFs } from 'goke'
const CONFIG_DIR = path.join(os.homedir(), '.cliname')
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json')
interface ServerConfig {
accessToken?: string
refreshToken?: string
expiresAt?: string
}
type Config = Record<string, ServerConfig>
async function loadConfig({ fs }: { fs: GokeFs }): Promise<Config> {
try {
return JSON.parse(await fs.readFile(CONFIG_PATH, 'utf-8'))
} catch {
return {}
}
}
async function saveConfig({ fs, config }: { fs: GokeFs; config: Config }) {
await fs.mkdir(CONFIG_DIR, { recursive: true })
await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n')
}
async function getServerConfig({ fs }: { fs: GokeFs }): Promise<ServerConfig> {
const config = await loadConfig({ fs })
return config[getApiUrl()] ?? {}
}
async function setServerConfig({ fs, data }: { fs: GokeFs; data: ServerConfig }) {
const apiUrl = getApiUrl()
const config = await loadConfig({ fs })
config[apiUrl] = { ...config[apiUrl], ...data }
await saveConfig({ fs, config })
}
```
### Using it in commands
Commands pass the injected `{ fs }` to config helpers and read the API URL via `getApiUrl()`.
```ts
cli
.command('login', 'Authenticate with the server')
.action(async (_options, { fs, console }) => {
const apiUrl = getApiUrl()
const token = await doLogin(apiUrl)
await setServerConfig({ fs, data: { accessToken: token } })
console.log(`Logged in to ${apiUrl}`)
})
cli
.command('status', 'Show current config')
.action(async (_options, { fs, console }) => {
const apiUrl = getApiUrl()
const server = await getServerConfig({ fs })
console.log(`Server: ${apiUrl}`)
console.log(`Authenticated: ${server.accessToken ? 'yes' : 'no'}`)
})
cli
.command('logout', 'Clear auth for current server')
.action(async (_options, { fs, console }) => {
const apiUrl = getApiUrl()
const config = await loadConfig({ fs })
delete config[apiUrl]
await saveConfig({ fs, config })
console.log(`Logged out from ${apiUrl}`)
})
```
### Why key by URL
- Users can be logged in to production and staging simultaneously
- Self-hosters get isolated auth without conflicting with the hosted service
- Developers can point to `http://localhost:3000` during development without losing their production token
- Switching servers is just `--api-url` or setting an env var; no re-login needed if the server was used before
```
## MCP CLIs (`@goke/mcp`)
Use `addMcpCommands` from `@goke/mcp`. Do not hide `--help` behind a token. Put token steps on a `config` command. Never write `isSetupCmd` or a first-run prompt that blocks no-args.
Use `getMcpUrl` plus `getHeaders` for HTTP Bearer tokens. Use `getMcpTransport` for stdio or any custom transport.
```ts
await addMcpCommands({
cli,
getMcpUrl: () => MCP_URL,
getHeaders: () => {
const token = process.env.TOKEN || loadConfig().token
if (!token) return
return { Authorization: `Bearer ${token}` }
},
loadCache: () => loadConfig().cache,
saveCache: (cache) => saveConfig({ cache }),
})
```
## Writing a Skill for a goke CLI
When creating a SKILL.md for a CLI built with goke, keep it **thin**. The skill is a bridge between the agent and the canonical docs, not a copy of them.
### Structure
1. **Always start with `--help`.** The CLI self-documents commands, options, and examples via goke's help generator. Make agents read it first, in full, every session. Add a "Read the help first" section with `mycli --help` and a rule to never truncate with `head`, `tail`, or `sed -n`.
2. **Point at canonical docs.** If the CLI has a docs site with `llms-full.txt` (e.g. built with Holocron), tell agents to `curl -s https://mycli.dev/llms-full.txt` as the single source of truth. For OSS repos without a docs site, curl the raw README from GitHub: `curl -s https://raw.githubusercontent.com/owner/repo/main/README.md`. Add a "never truncate" rule next to the curl command. Optionally list 3-4 individual page URLs for the most common quick lookups.
3. **Install and auth.** Just the commands, one per line, minimal prose. Start with `which mycli` so agents check before installing. Then `npm install -g mycli`, `mycli auth check`, `mycli auth login`, and `mycli auth set --key` for CI.
4. **Gotchas.** Agent-specific knowledge that is not obvious from `--help` or the docs. Things like browser flows that need user interaction, remote vs local file path restrictions, implicit defaults that cause silent failures, and subscription requirements.
5. **MCP alternative.** If the CLI has an MCP server, include the one-liner install command.
### What NOT to put in the skill
- **Command reference** — already in `--help` output
- **Workflow examples** — belong in the docs site (quickstart, guides)
- **Option descriptions** — already in Zod `.describe()` strings that goke renders in help
- **Output format details** — covered by `--help` and docs
If a section in the skill duplicates content from `--help` or the docs, delete it. The agent will fetch the canonical source.