gsjos 0.0.0-stage → 0.6.2
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 +179 -2
- package/bin/gsjos.mjs +16 -0
- package/package.json +41 -4
- package/src/cli.mjs +321 -0
- package/src/client.mjs +171 -0
- package/src/daemon.mjs +333 -0
- package/src/mcp.mjs +140 -0
- package/src/runtime.mjs +214 -0
- package/src/verbs.mjs +814 -0
package/README.md
CHANGED
|
@@ -1,3 +1,180 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `gsjos` — the OS client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Headless access to a gsjmedia OS workspace, with two faces over one core:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
gsjos people.list --q "acme" # CLI — humans, scripts, cron, CI, agents that shell out
|
|
7
|
+
gsjos pipeline.move <id> proposal_sent
|
|
8
|
+
gsjos mcp # MCP server (stdio) — Claude Code, Codex, Cursor, OpenClaw
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
This is **Layer 1** of [`architecture/11-agent-runtime-and-fleet-plan.md`](../../architecture/11-agent-runtime-and-fleet-plan.md):
|
|
12
|
+
the one door through which any agent reads context *and* takes actions. Runtimes are
|
|
13
|
+
replaceable; this is not.
|
|
14
|
+
|
|
15
|
+
## Why it is shaped like this
|
|
16
|
+
|
|
17
|
+
**One capability set, two syntaxes.** [`src/verbs.mjs`](src/verbs.mjs) defines every verb once —
|
|
18
|
+
name, arguments, route, human rendering. The CLI parses flags into it; the MCP server generates
|
|
19
|
+
JSON Schema from it. Adding a verb there adds a CLI command and an MCP tool simultaneously, with
|
|
20
|
+
identical semantics. Neither face can drift from the other because neither has its own logic.
|
|
21
|
+
|
|
22
|
+
**Everything goes through `/api/v1`, never Postgres directly.** So an agent gets the same
|
|
23
|
+
guardrails a human gets: legal status transitions enforced, the same triggers fired, the same
|
|
24
|
+
row-level security applied. An agent can be wrong in judgment; it cannot be wrong in data
|
|
25
|
+
integrity.
|
|
26
|
+
|
|
27
|
+
**Zero dependencies.** An agent host can `npx gsjos mcp` with nothing else installed,
|
|
28
|
+
and there is no SDK version to drift. The MCP stdio transport is ~120 lines of JSON-RPC.
|
|
29
|
+
|
|
30
|
+
## Setup
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm i -g gsjos
|
|
34
|
+
gsjos login # asks for API URL, workspace slug, and an API key
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`login` prompts only for what you did not pass, so it also runs where nothing can answer a
|
|
38
|
+
prompt — a provisioning script, or an agent session with no TTY:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
gsjos login --url https://os.gsjmedia.co --workspace gsjmedia --key gsjos_live_…
|
|
42
|
+
op read op://vault/gsjos/key | gsjos login --url … --workspace … --key - # keeps it out of history
|
|
43
|
+
GSJOS_API_KEY=gsjos_live_… gsjos login --url … --workspace …
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The key is read from `--key`, then `GSJOS_API_KEY`, then a prompt. With no TTY and neither of
|
|
47
|
+
the first two it fails naming them, rather than hanging on a question nothing will answer.
|
|
48
|
+
|
|
49
|
+
**Node 20+** for the CLI and the MCP server. **Node 22+ for `gsjos daemon`**, which needs a
|
|
50
|
+
global `WebSocket` to reach the local agent runtime — it fails at startup with that message
|
|
51
|
+
rather than midway through a run.
|
|
52
|
+
|
|
53
|
+
Create the key in the OS: **Settings → Agent runtime → API keys**. It is shown once.
|
|
54
|
+
|
|
55
|
+
A key is not a password and not a service-role key: the server binds it to a workspace agent
|
|
56
|
+
identity that sits in `workspace_members` with `role='agent'`, so requests made with it are held
|
|
57
|
+
to exactly the same row-level permissions as a person's. A key cannot reach another workspace.
|
|
58
|
+
|
|
59
|
+
Credentials resolve from the environment first, then `~/.gsjos/config.json` — so a laptop uses
|
|
60
|
+
`gsjos login` and a provisioned droplet gets them injected:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
GSJOS_API_URL=https://app.example.com
|
|
64
|
+
GSJOS_API_KEY=gsjos_live_…
|
|
65
|
+
GSJOS_WORKSPACE=gsjmedia
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## As an MCP server
|
|
69
|
+
|
|
70
|
+
Any MCP client, e.g. Claude Code:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
claude mcp add gsjos -- npx -y gsjos mcp
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Or by config, for a client that reads one:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"mcpServers": {
|
|
81
|
+
"gsjos": {
|
|
82
|
+
"command": "npx",
|
|
83
|
+
"args": ["-y", "gsjos", "mcp"],
|
|
84
|
+
"env": {
|
|
85
|
+
"GSJOS_API_URL": "https://app.example.com",
|
|
86
|
+
"GSJOS_API_KEY": "gsjos_live_…",
|
|
87
|
+
"GSJOS_WORKSPACE": "gsjmedia"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The server's `initialize` response tells the agent to call `schema` first. That verb returns the
|
|
95
|
+
workspace's status vocabularies, legal pipeline stages, and writable fields — generated from the
|
|
96
|
+
same modules the UI and the database constraints use, so it cannot go stale.
|
|
97
|
+
|
|
98
|
+
## Verbs
|
|
99
|
+
|
|
100
|
+
Run `gsjos help` for the list, `gsjos help <verb>` for one in detail.
|
|
101
|
+
|
|
102
|
+
| | |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `schema` | status vocabularies, legal transitions, writable fields |
|
|
105
|
+
| `people.list` · `people.get` · `people.update` | the prospect warehouse |
|
|
106
|
+
| `companies.list` | firmographics |
|
|
107
|
+
| `pipeline.list` · `pipeline.move` | deals by stage |
|
|
108
|
+
| `search` | people + companies by name |
|
|
109
|
+
| `interactions.log` | log a touch — inbound promotes to a lead via a DB trigger |
|
|
110
|
+
| `tam.list` · `tam.create` · `icp.list` · `icp.create` | targeting |
|
|
111
|
+
| `runs.list` · `runs.enqueue` · `agents.dispatch` | the agent work queue |
|
|
112
|
+
| `webhooks.list` · `webhooks.create` · `webhooks.delete` | subscribe a URL to workspace events |
|
|
113
|
+
|
|
114
|
+
Add `--json` to any verb for raw output instead of the human rendering.
|
|
115
|
+
|
|
116
|
+
### Walking a large workspace
|
|
117
|
+
|
|
118
|
+
List verbs are paginated. `--limit` sets the page size and `--cursor` takes you to the next
|
|
119
|
+
page, but you rarely need either — `--all` walks every page for you:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
gsjos people.list --all --json > people.json
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The pagination is keyset, not offset, so a walk of 50k people will not skip or repeat a row
|
|
126
|
+
even while the workspace is being written to underneath it. That is what makes `--all` safe
|
|
127
|
+
to point at an export.
|
|
128
|
+
|
|
129
|
+
### List values, and commas that are data
|
|
130
|
+
|
|
131
|
+
A list argument splits on commas. Plenty of real values contain one — the LinkedIn industry
|
|
132
|
+
`Technology, Information & Internet` is a targeting profile away from becoming two industries
|
|
133
|
+
nobody chose. Escape it, or repeat the flag:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
gsjos tam.create --name "Agencies" --industries 'Marketing Services,Technology\, Information & Internet'
|
|
137
|
+
gsjos tam.create --name "Agencies" --industries "Marketing Services" --industries "Technology, Information & Internet"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Both produce two entries, with the comma intact in the second. The MCP face takes a real
|
|
141
|
+
array, so this is a shell problem only.
|
|
142
|
+
|
|
143
|
+
### Re-running a write
|
|
144
|
+
|
|
145
|
+
The API replays a POST that carries an `Idempotency-Key` it has already seen, for 24h, rather
|
|
146
|
+
than running the handler again. `--idempotency-key` is how you send one:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
gsjos tam.create --name "Agencies" --idempotency-key tam-agencies-v1
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Run that script twice and you get one TAM and the same response, not two TAMs. Key it per
|
|
153
|
+
logical operation — not per retry — and a re-run of the whole script is safe. Reusing a key
|
|
154
|
+
with a *different* body is a 409, deliberately: that is a caller bug and hiding it would make
|
|
155
|
+
it permanent.
|
|
156
|
+
|
|
157
|
+
### Permissions
|
|
158
|
+
|
|
159
|
+
A key can be narrowed to individual verbs. If a call comes back with
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
this API key is missing the 'pipeline.move' scope
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
the key was issued for an agent whose `agent.md` does not declare that verb. Widen the grant
|
|
166
|
+
in the agent definition and re-issue the key — do not reach for a broader key.
|
|
167
|
+
|
|
168
|
+
## Tests
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
node test/smoke.mjs
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Drives the MCP server over a real stdio pipe against a stub API: protocol handshake, tool
|
|
175
|
+
listing, tool calls, error shapes, plus CLI argument parsing and auth headers. It asserts on
|
|
176
|
+
the request body the stub receives, not just the exit code — an argument that parsed into the
|
|
177
|
+
wrong shape is invisible from a 200. It covers every non-interactive `login` path, walks
|
|
178
|
+
a paginated stub end to end, and checks every verb here against the server's own scope
|
|
179
|
+
catalog in `app/src/lib/api/verbs.ts` — a verb the CLI can call but the server cannot scope
|
|
180
|
+
would be a hole in the permission model, so the two lists are not allowed to drift.
|
package/bin/gsjos.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { main } from "../src/cli.mjs";
|
|
3
|
+
|
|
4
|
+
const code = await main(process.argv.slice(2));
|
|
5
|
+
|
|
6
|
+
// `mcp` and `daemon` return null: they are long-running and own the process.
|
|
7
|
+
//
|
|
8
|
+
// Setting `exitCode` rather than calling `process.exit()` matters more than it
|
|
9
|
+
// looks. `process.exit()` tears the event loop down immediately, which (a) can
|
|
10
|
+
// truncate stdout when it is a pipe — exactly how this CLI is used from scripts
|
|
11
|
+
// and agents — and (b) trips a libuv assertion on Windows when HTTP keep-alive
|
|
12
|
+
// sockets are still pooled, which a `--all` walk always leaves behind. The
|
|
13
|
+
// process then dies with 0xC0000409 instead of the status the caller branches
|
|
14
|
+
// on. Letting Node exit on its own is immediate here anyway: the socket pool is
|
|
15
|
+
// unref'd, so nothing holds the loop open.
|
|
16
|
+
if (code !== null) process.exitCode = code;
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gsjos",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.6.2",
|
|
4
|
+
"description": "Headless access to a gsjmedia OS workspace — CLI and MCP server over one API core.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"homepage": "https://docs.gsjmedia.co",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/gsjmedia/gsjos.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/gsjmedia/gsjos/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"mcp",
|
|
17
|
+
"crm",
|
|
18
|
+
"agent",
|
|
19
|
+
"cli"
|
|
20
|
+
],
|
|
21
|
+
"bin": {
|
|
22
|
+
"gsjos": "bin/gsjos.mjs"
|
|
23
|
+
},
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./src/client.mjs",
|
|
26
|
+
"./verbs": "./src/verbs.mjs"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"bin",
|
|
30
|
+
"src",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "node test/smoke.mjs",
|
|
41
|
+
"prepublishOnly": "node test/smoke.mjs"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// CLI face — the same verbs, in shell syntax.
|
|
2
|
+
//
|
|
3
|
+
// Shell-native runtimes compose `gsjos` with pipes, cron, and scripts more
|
|
4
|
+
// naturally than they compose tool calls, so this is a first-class surface, not
|
|
5
|
+
// a debug tool for the MCP server.
|
|
6
|
+
|
|
7
|
+
import { createInterface } from "node:readline";
|
|
8
|
+
import { VERBS, verbByName } from "./verbs.mjs";
|
|
9
|
+
import { loadConfig, saveConfig, configPath, runVerb, call, GsjosError } from "./client.mjs";
|
|
10
|
+
import { startMcpServer } from "./mcp.mjs";
|
|
11
|
+
import { startDaemon } from "./daemon.mjs";
|
|
12
|
+
|
|
13
|
+
const ask = (question, { hidden } = {}) =>
|
|
14
|
+
new Promise((resolve) => {
|
|
15
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
16
|
+
if (hidden) rl._writeToOutput = (s) => { if (s.includes(question)) rl.output.write(question); };
|
|
17
|
+
rl.question(question, (answer) => {
|
|
18
|
+
rl.close();
|
|
19
|
+
if (hidden) process.stdout.write("\n");
|
|
20
|
+
resolve(answer.trim());
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** Read the whole of stdin. `--key -` uses this so a secret can be piped in
|
|
25
|
+
* from a password manager rather than sitting in shell history, in `ps`
|
|
26
|
+
* output, and in the environment of every child process. */
|
|
27
|
+
const readStdin = () =>
|
|
28
|
+
new Promise((resolve) => {
|
|
29
|
+
let buffer = "";
|
|
30
|
+
process.stdin.setEncoding("utf8");
|
|
31
|
+
process.stdin.on("data", (chunk) => (buffer += chunk));
|
|
32
|
+
process.stdin.on("end", () => resolve(buffer.trim()));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** `--flag value` and `--flag=value` pairs, with no verb spec to check against.
|
|
36
|
+
* For the commands that are not verbs — login, daemon. */
|
|
37
|
+
function plainFlags(argv) {
|
|
38
|
+
const flags = {};
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
if (!argv[i].startsWith("--")) continue;
|
|
41
|
+
const [name, inline] = argv[i].slice(2).split("=");
|
|
42
|
+
flags[name] = inline !== undefined ? inline : argv[++i] ?? "";
|
|
43
|
+
}
|
|
44
|
+
return flags;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Split a list flag on commas, honouring `\,` as a literal one.
|
|
48
|
+
*
|
|
49
|
+
* Real-world values contain commas — the LinkedIn industry
|
|
50
|
+
* "Technology, Information & Internet" is one — and splitting blindly sends
|
|
51
|
+
* two junk entries the caller never typed. Escaping is the only way to say
|
|
52
|
+
* "this comma is data" in a single shell token. */
|
|
53
|
+
const splitList = (raw) =>
|
|
54
|
+
String(raw)
|
|
55
|
+
.split(/(?<!\\),/)
|
|
56
|
+
.map((part) => part.replace(/\\,/g, ",").trim())
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
|
|
59
|
+
/** Parse `--flag value` pairs plus bare positionals, against a verb's arg spec. */
|
|
60
|
+
function parseArgs(verb, argv) {
|
|
61
|
+
const positionals = [];
|
|
62
|
+
const flags = {};
|
|
63
|
+
|
|
64
|
+
// A repeated flag accumulates for list args and last-wins for everything
|
|
65
|
+
// else. Repetition is the escape hatch when a value contains a comma AND the
|
|
66
|
+
// caller would rather not escape it: `--industries A --industries "B, C"`.
|
|
67
|
+
const set = (name, value) => {
|
|
68
|
+
const spec = verb.args[name];
|
|
69
|
+
flags[name] = spec?.type === "list" && flags[name] !== undefined
|
|
70
|
+
? [].concat(flags[name], value)
|
|
71
|
+
: value;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < argv.length; i++) {
|
|
75
|
+
const token = argv[i];
|
|
76
|
+
if (token.startsWith("--")) {
|
|
77
|
+
const [name, inline] = token.slice(2).split("=");
|
|
78
|
+
const spec = verb.args[name];
|
|
79
|
+
// Booleans take no value; everything else consumes the next token.
|
|
80
|
+
if (inline !== undefined) set(name, inline);
|
|
81
|
+
else if (spec?.type === "boolean") set(name, true);
|
|
82
|
+
else set(name, argv[++i]);
|
|
83
|
+
} else {
|
|
84
|
+
positionals.push(token);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const args = {};
|
|
89
|
+
const positionalNames = Object.entries(verb.args).filter(([, s]) => s.positional).map(([n]) => n);
|
|
90
|
+
positionalNames.forEach((name, i) => { if (positionals[i] !== undefined) args[name] = positionals[i]; });
|
|
91
|
+
|
|
92
|
+
for (const [name, raw] of Object.entries(flags)) {
|
|
93
|
+
const spec = verb.args[name];
|
|
94
|
+
if (!spec) throw new GsjosError(`${verb.name}: unknown option --${name}`, 400);
|
|
95
|
+
args[name] = raw;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Coerce to the declared types so the API gets numbers as numbers and
|
|
99
|
+
// comma-separated lists as arrays.
|
|
100
|
+
for (const [name, value] of Object.entries(args)) {
|
|
101
|
+
const spec = verb.args[name];
|
|
102
|
+
if (!spec || value === undefined) continue;
|
|
103
|
+
if (spec.type === "number") args[name] = Number(value);
|
|
104
|
+
else if (spec.type === "list") {
|
|
105
|
+
args[name] = (Array.isArray(value) ? value : [value]).flatMap(splitList);
|
|
106
|
+
} else if (spec.type === "objects") {
|
|
107
|
+
// The shell has no way to express a list of records, so it comes in as
|
|
108
|
+
// JSON. Failing here with the parse error beats sending a string the API
|
|
109
|
+
// will reject for a reason that has nothing to do with the real mistake.
|
|
110
|
+
if (typeof value === "string") {
|
|
111
|
+
try {
|
|
112
|
+
args[name] = JSON.parse(value);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
throw new Error(`--${name} must be valid JSON: ${e.message}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!Array.isArray(args[name])) args[name] = [args[name]];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return args;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function help() {
|
|
124
|
+
const lines = ["gsjos — headless access to a gsjmedia OS workspace", ""];
|
|
125
|
+
lines.push(" gsjos login [--url --workspace --key] store an API key + workspace");
|
|
126
|
+
lines.push(" gsjos whoami show the current config");
|
|
127
|
+
lines.push(" gsjos mcp run as an MCP server (stdio)");
|
|
128
|
+
lines.push(" gsjos help [verb|command] this, or one verb or command in detail");
|
|
129
|
+
lines.push("");
|
|
130
|
+
lines.push("Verbs:");
|
|
131
|
+
for (const v of VERBS) {
|
|
132
|
+
const positional = Object.entries(v.args).filter(([, s]) => s.positional).map(([n, s]) => (s.required ? `<${n}>` : `[${n}]`)).join(" ");
|
|
133
|
+
lines.push(` ${`${v.name} ${positional}`.padEnd(34)} ${v.summary.split(".")[0]}.`);
|
|
134
|
+
}
|
|
135
|
+
lines.push("");
|
|
136
|
+
lines.push("List verbs take --limit and --cursor; --all walks every page for you.");
|
|
137
|
+
lines.push("Any verb takes --json for raw output. A write takes --idempotency-key <k>:");
|
|
138
|
+
lines.push("re-running with the same key replays the first response instead of creating a second row.");
|
|
139
|
+
lines.push("A list value splits on commas; escape one you mean literally (Technology\\, Information).");
|
|
140
|
+
lines.push("Config: GSJOS_API_URL, GSJOS_API_KEY, GSJOS_WORKSPACE (env wins over ~/.gsjos/config.json).");
|
|
141
|
+
return lines.join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Detail for the commands that are not verbs.
|
|
145
|
+
*
|
|
146
|
+
* Without these, `gsjos help login` fell through to the general help — which
|
|
147
|
+
* reads as "login takes no arguments" rather than "look somewhere else", and
|
|
148
|
+
* that is exactly the question someone has when they type it. */
|
|
149
|
+
const COMMAND_HELP = {
|
|
150
|
+
login: [
|
|
151
|
+
"login — store an API URL, workspace slug and key in ~/.gsjos/config.json",
|
|
152
|
+
"",
|
|
153
|
+
" Prompts for whatever is not passed. Verifies the key against /schema",
|
|
154
|
+
" before writing it, so a typo fails here rather than inside an agent run",
|
|
155
|
+
" an hour later.",
|
|
156
|
+
"",
|
|
157
|
+
"Arguments:",
|
|
158
|
+
" --url <string> API URL, e.g. https://os.gsjmedia.co",
|
|
159
|
+
" --workspace <string> workspace slug",
|
|
160
|
+
" --key <string> API key; `--key -` reads it from stdin",
|
|
161
|
+
"",
|
|
162
|
+
"Non-interactive — a provisioning script, or an agent session with no TTY:",
|
|
163
|
+
" gsjos login --url https://os.gsjmedia.co --workspace acme --key gsjos_live_...",
|
|
164
|
+
" cat key.txt | gsjos login --url ... --workspace acme --key -",
|
|
165
|
+
" GSJOS_API_KEY=gsjos_live_... gsjos login --url ... --workspace acme",
|
|
166
|
+
"",
|
|
167
|
+
" The key is read from --key, then GSJOS_API_KEY, then a prompt. With no",
|
|
168
|
+
" TTY and neither of the first two, it fails naming them rather than",
|
|
169
|
+
" hanging on a prompt nothing can answer.",
|
|
170
|
+
].join("\n"),
|
|
171
|
+
|
|
172
|
+
whoami: "whoami — print the resolved API URL, workspace, key prefix and config path.",
|
|
173
|
+
|
|
174
|
+
mcp: [
|
|
175
|
+
"mcp — run as an MCP server over stdio (Claude Code, Codex, Cursor, OpenClaw).",
|
|
176
|
+
"",
|
|
177
|
+
" Every verb becomes a tool with the same arguments and validation the CLI",
|
|
178
|
+
" applies. stdout is the protocol channel; diagnostics go to stderr.",
|
|
179
|
+
].join("\n"),
|
|
180
|
+
|
|
181
|
+
daemon: [
|
|
182
|
+
"daemon — claim queued work from the OS and run it on this host.",
|
|
183
|
+
"",
|
|
184
|
+
"Arguments:",
|
|
185
|
+
" --poll <number> poll interval in ms",
|
|
186
|
+
"",
|
|
187
|
+
" Needs Node 22+ for a global WebSocket. Outbound only: the host needs",
|
|
188
|
+
" nothing inbound.",
|
|
189
|
+
].join("\n"),
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
function verbHelp(verb) {
|
|
193
|
+
const lines = [`${verb.name} — ${verb.summary}`, "", ` kind: ${verb.kind}`];
|
|
194
|
+
if (verb.paginated) lines.push(" paginated: --all walks every page (keyset, so no row is skipped or repeated)");
|
|
195
|
+
if (Object.keys(verb.args).length) {
|
|
196
|
+
lines.push("", "Arguments:");
|
|
197
|
+
for (const [name, spec] of Object.entries(verb.args)) {
|
|
198
|
+
const shape = spec.positional ? `<${name}>` : `--${name} <${spec.type}>`;
|
|
199
|
+
lines.push(` ${shape.padEnd(30)} ${spec.desc}${spec.required ? " (required)" : ""}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return lines.join("\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function main(argv) {
|
|
206
|
+
const [command, ...rest] = argv;
|
|
207
|
+
const config = loadConfig();
|
|
208
|
+
const wantsJson = rest.includes("--json");
|
|
209
|
+
|
|
210
|
+
// `--idempotency-key` is transport, not a verb argument — it belongs to every
|
|
211
|
+
// write the same way `--json` belongs to every read, so it is stripped here
|
|
212
|
+
// rather than declared 30 times in the verb list. Re-running a create with
|
|
213
|
+
// the same key replays the first response instead of writing a second row.
|
|
214
|
+
let idempotencyKey;
|
|
215
|
+
const args = [];
|
|
216
|
+
for (let i = 0; i < rest.length; i++) {
|
|
217
|
+
const token = rest[i];
|
|
218
|
+
if (token === "--json") continue;
|
|
219
|
+
if (token === "--idempotency-key") { idempotencyKey = rest[++i]; continue; }
|
|
220
|
+
if (token.startsWith("--idempotency-key=")) { idempotencyKey = token.slice("--idempotency-key=".length); continue; }
|
|
221
|
+
args.push(token);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!command || command === "help") {
|
|
225
|
+
const topic = args[0];
|
|
226
|
+
const verb = topic ? verbByName(topic) : null;
|
|
227
|
+
if (verb) console.log(verbHelp(verb));
|
|
228
|
+
else if (topic && COMMAND_HELP[topic]) console.log(COMMAND_HELP[topic]);
|
|
229
|
+
else if (topic) {
|
|
230
|
+
console.error(`No such verb or command '${topic}'.\n`);
|
|
231
|
+
console.log(help());
|
|
232
|
+
return 1;
|
|
233
|
+
} else console.log(help());
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (command === "login") {
|
|
238
|
+
const flags = plainFlags(args);
|
|
239
|
+
const unknown = Object.keys(flags).find((f) => !["url", "workspace", "key"].includes(f));
|
|
240
|
+
if (unknown) { console.error(`login: unknown option --${unknown}. Run \`gsjos help login\`.`); return 1; }
|
|
241
|
+
|
|
242
|
+
// A key can arrive three ways, and the order matters: an explicit flag is
|
|
243
|
+
// the caller being deliberate, the environment is a provisioned host, and
|
|
244
|
+
// the prompt is a person at a terminal. Only the last needs a TTY, so a
|
|
245
|
+
// headless session that passed one of the first two must not be sent to it.
|
|
246
|
+
let apiKey = flags.key === "-" ? await readStdin() : flags.key;
|
|
247
|
+
if (!apiKey) apiKey = process.env.GSJOS_API_KEY || "";
|
|
248
|
+
|
|
249
|
+
const canPrompt = Boolean(process.stdin.isTTY);
|
|
250
|
+
if (!apiKey && !canPrompt) {
|
|
251
|
+
console.error("login: no API key, and no terminal to ask for one.");
|
|
252
|
+
console.error("Pass --key <key>, pipe it in with --key -, or set GSJOS_API_KEY.");
|
|
253
|
+
return 1;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const apiUrl = flags.url || (canPrompt ? await ask(`API URL [${config.apiUrl}]: `) : "") || config.apiUrl;
|
|
257
|
+
const workspace = flags.workspace
|
|
258
|
+
|| (canPrompt ? await ask(`Workspace slug${config.workspace ? ` [${config.workspace}]` : ""}: `) : "")
|
|
259
|
+
|| config.workspace;
|
|
260
|
+
if (!apiKey) apiKey = await ask("API key (Settings → Agent runtime): ", { hidden: true });
|
|
261
|
+
if (!apiKey) { console.error("An API key is required."); return 1; }
|
|
262
|
+
|
|
263
|
+
const next = { apiUrl, workspace, apiKey };
|
|
264
|
+
try {
|
|
265
|
+
// Prove the key works before writing it, so a typo fails here rather than
|
|
266
|
+
// inside an agent run an hour later.
|
|
267
|
+
const schema = await call(next, { method: "GET", path: "/schema" });
|
|
268
|
+
const path = saveConfig(next);
|
|
269
|
+
console.log(`Connected to '${workspace}' — ${schema.counts?.people ?? 0} people, ${schema.counts?.leads ?? 0} leads.`);
|
|
270
|
+
console.log(`Saved to ${path}`);
|
|
271
|
+
return 0;
|
|
272
|
+
} catch (e) {
|
|
273
|
+
console.error(`Login failed: ${e.message}`);
|
|
274
|
+
return 1;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (command === "whoami") {
|
|
279
|
+
console.log(`workspace: ${config.workspace || "(default)"}`);
|
|
280
|
+
console.log(`api: ${config.apiUrl}`);
|
|
281
|
+
console.log(`key: ${config.apiKey ? `${config.apiKey.slice(0, 18)}…` : "(none)"}`);
|
|
282
|
+
console.log(`config: ${configPath()}`);
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (command === "mcp") {
|
|
287
|
+
startMcpServer(config);
|
|
288
|
+
return null; // long-running; the process stays alive on stdin
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (command === "daemon") {
|
|
292
|
+
// Runs on the agent host, next to the runtime. Claims queued work from the
|
|
293
|
+
// OS over an outbound connection, so the box needs nothing inbound.
|
|
294
|
+
const flags = plainFlags(args);
|
|
295
|
+
await startDaemon(config, {
|
|
296
|
+
pollMs: Number(flags.poll) > 0 ? Number(flags.poll) : undefined,
|
|
297
|
+
});
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const verb = verbByName(command);
|
|
302
|
+
if (!verb) {
|
|
303
|
+
console.error(`Unknown command '${command}'. Run \`gsjos help\`.`);
|
|
304
|
+
return 1;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (idempotencyKey && verb.kind !== "write") {
|
|
308
|
+
console.error(`${verb.name} is a read — --idempotency-key applies to writes only.`);
|
|
309
|
+
return 1;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
const data = await runVerb(config, verb.name, parseArgs(verb, args), { idempotencyKey });
|
|
314
|
+
if (wantsJson || !verb.text) console.log(JSON.stringify(data, null, 2));
|
|
315
|
+
else console.log(verb.text(data));
|
|
316
|
+
return 0;
|
|
317
|
+
} catch (e) {
|
|
318
|
+
console.error(e instanceof GsjosError ? e.message : String(e?.message ?? e));
|
|
319
|
+
return 1;
|
|
320
|
+
}
|
|
321
|
+
}
|