diff --git a/cli/package.json b/cli/package.json index 9ad908b..03ef6ec 100644 --- a/cli/package.json +++ b/cli/package.json @@ -3,6 +3,11 @@ "version": "0.1.0", "private": true, "type": "module", + "files": [ + "dist", + "skills", + "package.json" + ], "bin": { "spritz": "dist/index.js", "spz": "dist/index.js" @@ -14,9 +19,12 @@ "test": "node --test --import tsx" }, "dependencies": { + "skillflag": "^0.1.4", "ws": "^8.18.0" }, "devDependencies": { + "@types/node": "^22.19.15", + "@types/ws": "^8.18.1", "node-pty": "^1.0.0", "tsx": "^4.19.2", "typescript": "^5.6.3" diff --git a/cli/pnpm-lock.yaml b/cli/pnpm-lock.yaml index 71cd249..6a345ba 100644 --- a/cli/pnpm-lock.yaml +++ b/cli/pnpm-lock.yaml @@ -8,10 +8,19 @@ importers: .: dependencies: + skillflag: + specifier: ^0.1.4 + version: 0.1.4 ws: specifier: ^8.18.0 version: 8.19.0 devDependencies: + '@types/node': + specifier: ^22.19.15 + version: 22.19.15 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 node-pty: specifier: ^1.0.0 version: 1.1.0 @@ -24,6 +33,12 @@ importers: packages: + '@clack/core@1.1.0': + resolution: {integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==} + + '@clack/prompts@1.1.0': + resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==} + '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} @@ -180,11 +195,69 @@ packages: cpu: [x64] os: [win32] + '@types/node@22.19.15': + resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + b4a@1.8.0: + resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.5.5: + resolution: {integrity: sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.7.1: + resolution: {integrity: sha512-ebvMaS5BgZKmJlvuWh14dg9rbUI84QeV3WlWn6Ph6lFI8jJoh7ADtVTyD2c93euwbe+zgi0DVrl4YmqXeM9aIA==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.8.1: + resolution: {integrity: sha512-bSeR8RfvbRwDpD7HWZvn8M3uYNDrk7m9DQjYOFkENZlXW8Ju/MPaqUPQq5LqJ3kyjEm07siTaAQ7wBKCU59oHg==} + peerDependencies: + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.3.2: + resolution: {integrity: sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==} + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} hasBin: true + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -202,6 +275,26 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + skillflag@0.1.4: + resolution: {integrity: sha512-egFg+XCF5sloOWdtzxZivTX7n4UDj5pxQoY33wbT8h+YSDjMQJ76MZUg2rXQIBXmIDtlZhLgirS1g/3R5/qaHA==} + engines: {node: '>=18'} + hasBin: true + + streamx@2.23.0: + resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + + tar-stream@3.1.8: + resolution: {integrity: sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} @@ -212,6 +305,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + ws@8.19.0: resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} @@ -226,6 +322,15 @@ packages: snapshots: + '@clack/core@1.1.0': + dependencies: + sisteransi: 1.0.5 + + '@clack/prompts@1.1.0': + dependencies: + '@clack/core': 1.1.0 + sisteransi: 1.0.5 + '@esbuild/aix-ppc64@0.27.2': optional: true @@ -304,6 +409,49 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true + '@types/node@22.19.15': + dependencies: + undici-types: 6.21.0 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.15 + + b4a@1.8.0: {} + + bare-events@2.8.2: {} + + bare-fs@4.5.5: + dependencies: + bare-events: 2.8.2 + bare-path: 3.0.0 + bare-stream: 2.8.1(bare-events@2.8.2) + bare-url: 2.3.2 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.7.1: {} + + bare-path@3.0.0: + dependencies: + bare-os: 3.7.1 + + bare-stream@2.8.1(bare-events@2.8.2): + dependencies: + streamx: 2.23.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-url@2.3.2: + dependencies: + bare-path: 3.0.0 + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -333,6 +481,14 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + + fast-fifo@1.3.2: {} + fsevents@2.3.3: optional: true @@ -348,6 +504,50 @@ snapshots: resolve-pkg-maps@1.0.0: {} + sisteransi@1.0.5: {} + + skillflag@0.1.4: + dependencies: + '@clack/prompts': 1.1.0 + tar-stream: 3.1.8 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + streamx@2.23.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + tar-stream@3.1.8: + dependencies: + b4a: 1.8.0 + bare-fs: 4.5.5 + fast-fifo: 1.3.2 + streamx: 2.23.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.23.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.0 + transitivePeerDependencies: + - react-native-b4a + tsx@4.21.0: dependencies: esbuild: 0.27.2 @@ -357,4 +557,6 @@ snapshots: typescript@5.9.3: {} + undici-types@6.21.0: {} + ws@8.19.0: {} diff --git a/cli/skills/spz/SKILL.md b/cli/skills/spz/SKILL.md new file mode 100644 index 0000000..edcc9b1 --- /dev/null +++ b/cli/skills/spz/SKILL.md @@ -0,0 +1,161 @@ +--- +name: spz +description: Use the spz CLI to provision, inspect, and access Spritz workspaces, including service-principal create flows, preset-based provisioning, canonical access URLs, and ACP-ready workspace operations. +--- + +# spz + +## When to use this skill + +Use this skill when you need to interact with a Spritz control plane through the `spz` CLI. + +Typical cases: + +- create a new workspace from a preset such as `openclaw` or `claude-code` +- create a workspace on behalf of a user with a service-principal bearer token +- suggest a DNS-safe random name for a workspace +- inspect a workspace URL +- attach to terminal access +- manage local `spz` profiles for different Spritz environments + +## What Spritz is + +Spritz is a control plane for agent workspaces. + +Core model: + +- a workspace is a `Spritz` resource +- the workspace may expose ACP on port `2529` +- Spritz owns provisioning, routing, auth, canonical URLs, and lifecycle +- the backend image owns the runtime itself + +For external provisioners: + +- the human remains the owner +- the service principal is only the actor that created the workspace +- create-only service principals should not be able to edit, delete, terminal into, or list user workspaces unless explicitly granted + +## Authentication modes + +`spz` supports two auth models. + +### 1. Bearer token + +Use this for services, bots, and automation. + +- env: `SPRITZ_BEARER_TOKEN` +- flag: `--token` + +This is the preferred mode for external provisioners such as bots. + +### 2. Header-based user identity + +Use this for local or trusted internal environments. + +- `SPRITZ_USER_ID` +- `SPRITZ_USER_EMAIL` +- `SPRITZ_USER_TEAMS` + +This mode is not the right fit for external automation. + +## Important environment variables + +- `SPRITZ_API_URL`: Spritz API base URL, for example `https://console.example.com/api` +- `SPRITZ_BEARER_TOKEN`: service-principal bearer token +- `SPRITZ_CONFIG_DIR`: config directory for profiles +- `SPRITZ_PROFILE`: active profile name + +## Service-principal create flow + +For external provisioners, the normal command is: + +```bash +spz create \ + --owner-id user-123 \ + --preset openclaw \ + --idle-ttl 24h \ + --ttl 168h \ + --idempotency-key discord-interaction-123 \ + --source discord \ + --request-id discord-interaction-123 \ + --json +``` + +Rules: + +- `owner-id` is the human who should own the workspace +- the service principal is only the actor +- the same `idempotency-key` and same request should replay the same workspace +- the same `idempotency-key` with a different request should fail with conflict +- the response should include the canonical access URL + +## Common commands + +Create from a preset: + +```bash +spz create --preset openclaw --owner-id user-123 --idle-ttl 24h --ttl 168h --idempotency-key req-123 --json +``` + +Create from an explicit image: + +```bash +spz create --image example.com/spritz-devbox:latest --owner-id user-123 --idempotency-key req-123 --json +``` + +Suggest a name: + +```bash +spz suggest-name --preset claude-code +``` + +Open a workspace URL: + +```bash +spz open openclaw-tide-wind +``` + +List workspaces: + +```bash +spz list +``` + +Open a terminal: + +```bash +spz terminal openclaw-tide-wind +``` + +Use profiles: + +```bash +spz profile set staging --api-url https://console.example.com/api --namespace spritz +spz profile use staging +``` + +## Operational expectations + +- prefer `--preset` over `--image` when a preset exists +- prefer bearer-token auth for bots +- treat the create response as the source of truth for the access URL +- do not construct workspace URLs yourself +- use idempotency keys for any retried or externally triggered create operation +- for service principals, expect create to succeed and list/delete to be denied unless extra scopes were granted + +## Bundled skill usage + +This package includes a bundled `spz` skill for Codex-compatible environments. + +Install it into the current user's Codex skill directory with: + +```bash +spz --skill install spz --agent codex --scope user --force +``` + +Inspect it with: + +```bash +spz --skill show spz +spz --skill list +``` diff --git a/cli/src/index.ts b/cli/src/index.ts index 962568e..861628b 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -29,6 +29,8 @@ type TerminalSessionInfo = { default_session?: string; }; +type SkillflagModule = typeof import('skillflag'); + type TtyContext = { ttyPath: string | null; ttyState: string | null; @@ -54,6 +56,16 @@ const ttyWatchdogIntervalMs = 250; const ttyRestoreBanner = '\r\n[spz] terminal restored after disconnect\r\n'; const sttyBinary = process.env.SPRITZ_STTY_BINARY || 'stty'; const resetBinary = process.env.SPRITZ_RESET_BINARY || 'reset'; +let skillflagModulePromise: Promise | undefined; + +function loadSkillflagModule(): Promise { + skillflagModulePromise ??= import('skillflag'); + return skillflagModulePromise; +} + +function shouldMaybeHandleSkillflag(argv: string[]): boolean { + return argv.some((token) => token === '--skill' || token.startsWith('--skill=')); +} /** * Build platform-specific stty args that target a specific tty path. @@ -374,6 +386,7 @@ Usage: spritz profile set [--api-url ] [--user-id ] [--user-email ] [--user-teams ] [--namespace ] spritz profile use spritz profile delete + spritz --skill ... Alias: spz (same commands as spritz) @@ -924,6 +937,15 @@ async function resolveNamespace(): Promise { } async function main() { + if (shouldMaybeHandleSkillflag(process.argv)) { + const { findSkillsRoot, maybeHandleSkillflag } = await loadSkillflagModule(); + await maybeHandleSkillflag(process.argv, { + skillsRoot: findSkillsRoot(import.meta.url), + includeBundledSkill: false, + }); + return; + } + if (!command || command === 'help' || command === '--help') { usage(); return; diff --git a/cli/test/skillflag.test.ts b/cli/test/skillflag.test.ts new file mode 100644 index 0000000..de1b77a --- /dev/null +++ b/cli/test/skillflag.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const cliPath = path.join(__dirname, '..', 'src', 'index.ts'); + +async function runCli(args: string[]) { + const child = spawn(process.execPath, ['--import', 'tsx', cliPath, ...args], { + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + const code = await new Promise((resolve) => child.on('exit', resolve)); + return { code, stdout, stderr }; +} + +test('skillflag list exposes the bundled spz skill', async () => { + const result = await runCli(['--skill', 'list']); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /(^|\n)spz(\t|$)/); +}); + +test('skillflag show returns the bundled spz skill body', async () => { + const result = await runCli(['--skill', 'show', 'spz']); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /# spz/); + assert.match(result.stdout, /service-principal create flow/i); +});