teamshift 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # teamshift
2
+
3
+ Run TeamShift agent teams from your terminal. Metered by usage — you do **not**
4
+ bring your own model key, so a run costs you TeamShift credit rather than an
5
+ Anthropic or OpenAI bill.
6
+
7
+ ```bash
8
+ npx teamshift login
9
+ npx teamshift catalog # prebuilt teams you can add
10
+ npx teamshift add code-audit-swarm # adds it to your workspace
11
+ npx teamshift run <team-id> "review services/api for auth mistakes"
12
+ ```
13
+
14
+ `teamshift teams` lists everything callable, including teams you built yourself
15
+ in the portal. `add` is idempotent — running it twice will not duplicate a team
16
+ or overwrite edits you have made to it.
17
+
18
+ ## Parallel fan-out
19
+
20
+ The reason to reach for the CLI instead of the API: one command dispatches many
21
+ prompts at once and reports what each one cost.
22
+
23
+ ```bash
24
+ npx teamshift run <team-id> \
25
+ -p "audit auth" -p "map the schema" -p "find dead code" --concurrency 3
26
+ ```
27
+
28
+ ```
29
+ STATUS CHARGE TIME PROMPT
30
+ ───────── ────── ──── ───────────────
31
+ completed $1.20 4.1s audit auth
32
+ completed $1.20 4.0s map the schema
33
+ completed $1.20 4.0s find dead code
34
+
35
+ 3 run(s) · $3.60 total · 8.1s wall clock (12.1s if run one at a time)
36
+ ```
37
+
38
+ `--file prompts.txt` reads one prompt per line. Concurrency is bounded (default
39
+ 5) so a large batch does not trip the API's write rate limit.
40
+
41
+ ## Already using Claude Code or Codex?
42
+
43
+ Prefer the MCP server — it exposes the same teams as tools inside your existing
44
+ agent, with no second CLI to learn. Both install commands are in the portal under
45
+ **Programmatic setup**.
46
+
47
+ ## Auth
48
+
49
+ `TEAMSHIFT_API_KEY` wins over the stored config, so CI never has to write a file.
50
+ `teamshift login` writes `~/.teamshift/config.json` at mode `0600`.
51
+ `TEAMSHIFT_API_URL` overrides the endpoint.
52
+
53
+ ## Exit codes
54
+
55
+ | Code | Meaning |
56
+ | --- | --- |
57
+ | `0` | every run reached a terminal status |
58
+ | `1` | usage error, auth failure, or at least one run failed to dispatch |
59
+ | `2` | out of credit — add credits and rerun |
60
+
61
+ ## Releasing
62
+
63
+ This package is `private: true` so a workspace-wide publish cannot push it by
64
+ accident. Releasing is deliberate: set `private: false`, `pnpm build`, then
65
+ `npm publish`.
package/dist/api.js ADDED
@@ -0,0 +1,76 @@
1
+ /** A 402 from the API means "out of credit", which is a normal, actionable state. */
2
+ export class PaymentRequiredError extends Error {
3
+ detail;
4
+ constructor(detail) {
5
+ super('payment required');
6
+ this.detail = detail;
7
+ }
8
+ }
9
+ export class ApiError extends Error {
10
+ status;
11
+ detail;
12
+ constructor(status, detail) {
13
+ super(`${status}: ${detail}`);
14
+ this.status = status;
15
+ this.detail = detail;
16
+ }
17
+ }
18
+ export class TeamShiftClient {
19
+ credentials;
20
+ constructor(credentials) {
21
+ this.credentials = credentials;
22
+ }
23
+ async request(path, init = {}) {
24
+ const response = await fetch(`${this.credentials.apiUrl}${path}`, {
25
+ ...init,
26
+ headers: {
27
+ Authorization: `Bearer ${this.credentials.apiKey}`,
28
+ 'Content-Type': 'application/json',
29
+ ...(init.headers ?? {}),
30
+ },
31
+ });
32
+ if (response.ok)
33
+ return (await response.json());
34
+ const detail = await this.readDetail(response);
35
+ if (response.status === 402)
36
+ throw new PaymentRequiredError(detail);
37
+ throw new ApiError(response.status, detail);
38
+ }
39
+ async readDetail(response) {
40
+ try {
41
+ const body = (await response.json());
42
+ const detail = body.detail ?? body.reason ?? body.error;
43
+ return typeof detail === 'string' ? detail : JSON.stringify(body);
44
+ }
45
+ catch {
46
+ return response.statusText || 'request failed';
47
+ }
48
+ }
49
+ async catalog() {
50
+ return (await this.request('/v1/team-catalog')).teams;
51
+ }
52
+ activate(catalogId) {
53
+ return this.request(`/v1/team-catalog/${catalogId}/activate`, {
54
+ method: 'POST',
55
+ });
56
+ }
57
+ interop() {
58
+ return this.request('/v1/interop');
59
+ }
60
+ /**
61
+ * Start a run. The API returns 202 with a run id rather than a result, which
62
+ * is what makes fan-out cheap: N runs are N dispatches, then one poll loop.
63
+ */
64
+ async startRun(kind, id, message) {
65
+ const path = kind === 'team' ? `/v1/teamshift-teams/${id}/runs` : `/v1/teamshift-agents/${id}/runs`;
66
+ const accepted = await this.request(path, {
67
+ method: 'POST',
68
+ body: JSON.stringify({ message, payload: {} }),
69
+ });
70
+ return accepted.workflow_run_id;
71
+ }
72
+ async getRun(runId) {
73
+ const body = await this.request(`/v1/runs/${runId}`);
74
+ return body.run;
75
+ }
76
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { PaymentRequiredError, TeamShiftClient } from './api.js';
4
+ import { DEFAULT_API_URL, loadCredentials, maskKey, saveCredentials } from './config.js';
5
+ import { chargeUsd, duration, money, table } from './format.js';
6
+ import { runBatch } from './run.js';
7
+ const DEFAULT_MARKUP = 3;
8
+ const USAGE = `teamshift — run agent teams from your terminal
9
+
10
+ teamshift login Store an API key (or set TEAMSHIFT_API_KEY)
11
+ teamshift whoami Show the active key and endpoint
12
+ teamshift catalog Browse prebuilt teams you can add
13
+ teamshift add <catalog-id> Add a prebuilt team to your workspace
14
+ teamshift teams List callable teams and agents
15
+ teamshift run <id> "<prompt>" Run one team, wait, print status and charge
16
+ teamshift run <id> -p "a" -p "b" Fan out prompts in parallel
17
+ teamshift run <id> --file prompts.txt One prompt per line, run in parallel
18
+
19
+ Options
20
+ --agent Target an agent id instead of a team id
21
+ --concurrency <n> Parallel runs in flight (default 5)
22
+ --json Emit machine-readable JSON
23
+
24
+ Runs are billed on metered usage — you do not bring your own model key.`;
25
+ export function parseArgs(argv) {
26
+ const parsed = {
27
+ command: argv[0] ?? 'help',
28
+ positional: [],
29
+ prompts: [],
30
+ agent: false,
31
+ json: false,
32
+ concurrency: 5,
33
+ };
34
+ for (let index = 1; index < argv.length; index += 1) {
35
+ const token = argv[index];
36
+ if (token === '-p' || token === '--prompt')
37
+ parsed.prompts.push(argv[++index] ?? '');
38
+ else if (token === '--file')
39
+ parsed.file = argv[++index];
40
+ else if (token === '--concurrency')
41
+ parsed.concurrency = Number(argv[++index] ?? 5);
42
+ else if (token === '--agent')
43
+ parsed.agent = true;
44
+ else if (token === '--json')
45
+ parsed.json = true;
46
+ else
47
+ parsed.positional.push(token);
48
+ }
49
+ return parsed;
50
+ }
51
+ function requireClient() {
52
+ const credentials = loadCredentials();
53
+ if (!credentials) {
54
+ console.error('Not logged in. Run `teamshift login`, or set TEAMSHIFT_API_KEY.');
55
+ process.exit(1);
56
+ }
57
+ return new TeamShiftClient(credentials);
58
+ }
59
+ async function login() {
60
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
61
+ try {
62
+ const apiKey = (await rl.question('TeamShift API key: ')).trim();
63
+ if (!apiKey) {
64
+ console.error('No key entered.');
65
+ process.exit(1);
66
+ }
67
+ const apiUrl = (await rl.question(`API URL [${DEFAULT_API_URL}]: `)).trim() || DEFAULT_API_URL;
68
+ const path = saveCredentials({ apiKey, apiUrl });
69
+ console.log(`Saved ${maskKey(apiKey)} to ${path}`);
70
+ }
71
+ finally {
72
+ rl.close();
73
+ }
74
+ }
75
+ async function listCallable(json) {
76
+ const payload = await requireClient().interop();
77
+ if (json) {
78
+ console.log(JSON.stringify(payload, null, 2));
79
+ return;
80
+ }
81
+ const rows = [
82
+ ...payload.teams.map((item) => ['team', item.id, item.name]),
83
+ ...payload.agents.map((item) => ['agent', item.id, item.name]),
84
+ ];
85
+ if (rows.length === 0) {
86
+ console.log('Nothing callable yet — create a team in the portal first.');
87
+ return;
88
+ }
89
+ console.log(table(['KIND', 'ID', 'NAME'], rows));
90
+ }
91
+ async function showCatalog(json) {
92
+ const teams = await requireClient().catalog();
93
+ if (json) {
94
+ console.log(JSON.stringify(teams, null, 2));
95
+ return;
96
+ }
97
+ console.log(table(['ID', 'NAME', 'ADDED', 'BEST FOR'], teams.map((team) => [team.id, team.name, team.activated ? 'yes' : '—', team.best_for])));
98
+ console.log('\nAdd one with: teamshift add <id>');
99
+ }
100
+ async function addFromCatalog(args) {
101
+ const catalogId = args.positional[0];
102
+ if (!catalogId) {
103
+ console.error('Usage: teamshift add <catalog-id> (see `teamshift catalog`)');
104
+ process.exit(1);
105
+ }
106
+ const result = await requireClient().activate(catalogId);
107
+ if (args.json) {
108
+ console.log(JSON.stringify(result, null, 2));
109
+ return;
110
+ }
111
+ // Activation is idempotent server-side; say so rather than implying a new team.
112
+ console.log(result.created ? `Added ${result.name}.` : `${result.name} was already in your workspace.`);
113
+ console.log(`\n ${result.run_command}`);
114
+ }
115
+ async function run(args) {
116
+ const client = requireClient();
117
+ const id = args.positional[0];
118
+ if (!id) {
119
+ console.error('Usage: teamshift run <id> "<prompt>"');
120
+ process.exit(1);
121
+ }
122
+ const prompts = await collectPrompts(args);
123
+ if (prompts.length === 0) {
124
+ console.error('No prompt given. Pass one inline, with -p, or via --file.');
125
+ process.exit(1);
126
+ }
127
+ const kind = args.agent ? 'agent' : 'team';
128
+ const startedAt = Date.now();
129
+ if (!args.json) {
130
+ console.log(`Running ${prompts.length} prompt(s) against ${kind} ${id}…`);
131
+ }
132
+ const outcomes = await runBatch(client, kind, id, prompts, {
133
+ concurrency: args.concurrency,
134
+ onSettled: (outcome) => {
135
+ if (args.json)
136
+ return;
137
+ const charge = money(chargeUsd(outcome.costUsd, DEFAULT_MARKUP));
138
+ console.log(` ${outcome.status.padEnd(16)} ${duration(outcome.elapsedMs).padStart(7)} ${charge}`);
139
+ },
140
+ });
141
+ if (args.json) {
142
+ console.log(JSON.stringify({ outcomes }, null, 2));
143
+ return;
144
+ }
145
+ console.log(`\n${table(['STATUS', 'CHARGE', 'TIME', 'PROMPT'], outcomes.map((outcome) => [
146
+ outcome.status,
147
+ money(chargeUsd(outcome.costUsd, DEFAULT_MARKUP)),
148
+ duration(outcome.elapsedMs),
149
+ outcome.prompt.length > 48 ? `${outcome.prompt.slice(0, 47)}…` : outcome.prompt,
150
+ ]))}`);
151
+ const total = outcomes.reduce((sum, outcome) => sum + (chargeUsd(outcome.costUsd, DEFAULT_MARKUP) ?? 0), 0);
152
+ // Wall-clock vs summed run time is the whole pitch for fan-out, so show both.
153
+ const serial = outcomes.reduce((sum, outcome) => sum + outcome.elapsedMs, 0);
154
+ console.log(`\n${outcomes.length} run(s) · ${money(total)} total · ${duration(Date.now() - startedAt)} wall clock ` +
155
+ `(${duration(serial)} if run one at a time)`);
156
+ const failed = outcomes.filter((outcome) => outcome.error).length;
157
+ if (failed > 0) {
158
+ console.log(`${failed} run(s) failed to dispatch — rerun just those prompts.`);
159
+ process.exitCode = 1;
160
+ }
161
+ }
162
+ async function collectPrompts(args) {
163
+ const prompts = [...args.prompts];
164
+ if (args.file) {
165
+ const { readFile } = await import('node:fs/promises');
166
+ const contents = await readFile(args.file, 'utf8');
167
+ prompts.push(...contents.split('\n').map((line) => line.trim()).filter(Boolean));
168
+ }
169
+ const inline = args.positional.slice(1).join(' ').trim();
170
+ if (inline)
171
+ prompts.push(inline);
172
+ return prompts;
173
+ }
174
+ export async function main(argv = process.argv.slice(2)) {
175
+ const args = parseArgs(argv);
176
+ try {
177
+ switch (args.command) {
178
+ case 'login':
179
+ return await login();
180
+ case 'whoami': {
181
+ const credentials = loadCredentials();
182
+ console.log(credentials ? `${maskKey(credentials.apiKey)} → ${credentials.apiUrl}` : 'Not logged in.');
183
+ return;
184
+ }
185
+ case 'catalog':
186
+ return await showCatalog(args.json);
187
+ case 'add':
188
+ return await addFromCatalog(args);
189
+ case 'teams':
190
+ return await listCallable(args.json);
191
+ case 'run':
192
+ return await run(args);
193
+ default:
194
+ console.log(USAGE);
195
+ }
196
+ }
197
+ catch (error) {
198
+ if (error instanceof PaymentRequiredError) {
199
+ console.error(`Out of credit: ${error.detail}`);
200
+ console.error('Add credits in the portal, then rerun.');
201
+ process.exit(2);
202
+ }
203
+ console.error(error instanceof Error ? error.message : String(error));
204
+ process.exit(1);
205
+ }
206
+ }
207
+ const invokedDirectly = process.argv[1]?.endsWith('cli.js') || process.argv[1]?.endsWith('cli.ts');
208
+ if (invokedDirectly)
209
+ void main();
package/dist/config.js ADDED
@@ -0,0 +1,47 @@
1
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ export const DEFAULT_API_URL = 'https://api.teamshift.io';
5
+ export function configPath(env = process.env) {
6
+ return env.TEAMSHIFT_CONFIG ?? join(homedir(), '.teamshift', 'config.json');
7
+ }
8
+ /**
9
+ * Resolve credentials, environment first.
10
+ *
11
+ * The env var wins so CI and one-off shells never have to touch the config file,
12
+ * and so a shared machine can run as a different tenant without rewriting state.
13
+ */
14
+ export function loadCredentials(env = process.env) {
15
+ const apiUrl = env.TEAMSHIFT_API_URL ?? readStored(env)?.apiUrl ?? DEFAULT_API_URL;
16
+ const apiKey = env.TEAMSHIFT_API_KEY ?? readStored(env)?.apiKey;
17
+ return apiKey ? { apiKey, apiUrl } : null;
18
+ }
19
+ function readStored(env) {
20
+ try {
21
+ const parsed = JSON.parse(readFileSync(configPath(env), 'utf8'));
22
+ if (typeof parsed !== 'object' || parsed === null)
23
+ return null;
24
+ const record = parsed;
25
+ return {
26
+ apiKey: typeof record.apiKey === 'string' ? record.apiKey : undefined,
27
+ apiUrl: typeof record.apiUrl === 'string' ? record.apiUrl : undefined,
28
+ };
29
+ }
30
+ catch {
31
+ // No config yet, or an unreadable/corrupt one — treat both as "not logged
32
+ // in" so a broken file prompts a fresh login instead of crashing the CLI.
33
+ return null;
34
+ }
35
+ }
36
+ /** Persist credentials 0600 — this file holds a live billing credential. */
37
+ export function saveCredentials(credentials, env = process.env) {
38
+ const target = configPath(env);
39
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
40
+ writeFileSync(target, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
41
+ chmodSync(target, 0o600);
42
+ return target;
43
+ }
44
+ /** Show enough of a key to identify it, never enough to use it. */
45
+ export function maskKey(apiKey) {
46
+ return apiKey.length <= 8 ? '****' : `${apiKey.slice(0, 6)}…${apiKey.slice(-4)}`;
47
+ }
package/dist/format.js ADDED
@@ -0,0 +1,42 @@
1
+ export const TERMINAL_STATUSES = new Set([
2
+ 'completed',
3
+ 'failed',
4
+ 'canceled',
5
+ 'terminated',
6
+ 'timed_out',
7
+ 'approval_expired',
8
+ 'blocked',
9
+ 'guardrail_tripped',
10
+ ]);
11
+ export function isTerminal(status) {
12
+ return TERMINAL_STATUSES.has(String(status ?? ''));
13
+ }
14
+ /**
15
+ * Charge in USD for a run's reconciled cost, at the metered markup.
16
+ *
17
+ * Mirrors the server's resolver rather than inventing a number: the CLI only
18
+ * ever PREVIEWS what reconciliation will debit, so it must not round somewhere
19
+ * the server does not. Returns null for an unreconciled run so the caller can
20
+ * print "pending" instead of a misleading $0.00.
21
+ */
22
+ export function chargeUsd(costUsd, markup) {
23
+ if (costUsd === null || costUsd === undefined || costUsd === '')
24
+ return null;
25
+ const cost = typeof costUsd === 'number' ? costUsd : Number(costUsd);
26
+ if (!Number.isFinite(cost) || cost < 0)
27
+ return null;
28
+ return Math.max(Math.ceil(Number((cost * markup * 100).toFixed(6))), cost > 0 ? 1 : 0) / 100;
29
+ }
30
+ export function money(value) {
31
+ return value === null ? '—' : `$${value.toFixed(2)}`;
32
+ }
33
+ export function duration(ms) {
34
+ const seconds = Math.round(ms / 100) / 10;
35
+ return seconds < 60 ? `${seconds.toFixed(1)}s` : `${Math.floor(seconds / 60)}m${Math.round(seconds % 60)}s`;
36
+ }
37
+ /** Fixed-width table so a fan-out of many runs stays scannable in a terminal. */
38
+ export function table(headers, rows) {
39
+ const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? '').length)));
40
+ const line = (cells) => cells.map((cell, index) => (cell ?? '').padEnd(widths[index] ?? 0)).join(' ').trimEnd();
41
+ return [line(headers), line(widths.map((width) => '─'.repeat(width))), ...rows.map(line)].join('\n');
42
+ }
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from './api.js';
2
+ export * from './config.js';
3
+ export * from './format.js';
4
+ export * from './run.js';
5
+ export { parseArgs } from './cli.js';
package/dist/run.js ADDED
@@ -0,0 +1,63 @@
1
+ import { isTerminal } from './format.js';
2
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3
+ /** Poll one run to a terminal status, or give up at the timeout. */
4
+ export async function waitForRun(client, runId, options = {}) {
5
+ const { intervalMs = 2000, timeoutMs = 900_000, now = Date.now, sleep = defaultSleep } = options;
6
+ const startedAt = now();
7
+ let latest = await client.getRun(runId);
8
+ while (!isTerminal(latest.status)) {
9
+ if (now() - startedAt >= timeoutMs)
10
+ return latest;
11
+ await sleep(intervalMs);
12
+ latest = await client.getRun(runId);
13
+ }
14
+ return latest;
15
+ }
16
+ /**
17
+ * Dispatch many prompts against one team and collect every result.
18
+ *
19
+ * Bounded concurrency rather than an unbounded Promise.all: a fan-out of a few
20
+ * hundred prompts would otherwise open that many sockets at once and trip the
21
+ * API's write rate limit, turning a large batch into a wall of 429s.
22
+ *
23
+ * A failed run never rejects the batch — it is recorded as its own outcome, so
24
+ * one bad prompt cannot discard the work of every sibling that succeeded.
25
+ */
26
+ export async function runBatch(client, kind, id, prompts, options = {}) {
27
+ const { concurrency = 5, onSettled, now = Date.now, ...pollOptions } = options;
28
+ const results = new Array(prompts.length);
29
+ let cursor = 0;
30
+ const worker = async () => {
31
+ for (;;) {
32
+ const index = cursor++;
33
+ const prompt = prompts[index];
34
+ if (prompt === undefined)
35
+ return;
36
+ const startedAt = now();
37
+ try {
38
+ const runId = await client.startRun(kind, id, prompt);
39
+ const summary = await waitForRun(client, runId, { ...pollOptions, now });
40
+ results[index] = {
41
+ prompt,
42
+ runId,
43
+ status: String(summary.status ?? 'unknown'),
44
+ costUsd: summary.cost_estimate_usd ?? null,
45
+ elapsedMs: now() - startedAt,
46
+ };
47
+ }
48
+ catch (error) {
49
+ results[index] = {
50
+ prompt,
51
+ runId: null,
52
+ status: 'dispatch_failed',
53
+ costUsd: null,
54
+ elapsedMs: now() - startedAt,
55
+ error: error instanceof Error ? error.message : String(error),
56
+ };
57
+ }
58
+ onSettled?.(results[index]);
59
+ }
60
+ };
61
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, prompts.length)) }, worker));
62
+ return results;
63
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "teamshift",
3
+ "private": false,
4
+ "version": "0.1.0",
5
+ "description": "Run TeamShift agent teams from your terminal. Metered by usage — no model key of your own required.",
6
+ "type": "module",
7
+ "bin": {
8
+ "teamshift": "dist/cli.js"
9
+ },
10
+ "main": "src/index.ts",
11
+ "types": "src/index.ts",
12
+ "files": [
13
+ "dist",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.build.json",
21
+ "typecheck": "tsc -p tsconfig.json --noEmit",
22
+ "lint": "tsc -p tsconfig.json --noEmit",
23
+ "test": "vitest run"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^22.10.1",
27
+ "typescript": "^5.6.3",
28
+ "vitest": "^2.1.8"
29
+ }
30
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './api.js';
2
+ export * from './config.js';
3
+ export * from './format.js';
4
+ export * from './run.js';
5
+ export { parseArgs } from './cli.js';