@sechroom/cli 2026.6.1
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 +144 -0
- package/dist/index.js +341 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# sechroom-cli (Route 1 skeleton)
|
|
2
|
+
|
|
3
|
+
A thin **npm-distributed** CLI over the Sechroom HTTP API. A deliberate
|
|
4
|
+
alternative agent/human surface to MCP — and because the agentic coding tools
|
|
5
|
+
(Claude Code, Codex, Cursor) drive CLIs by reading `--help` and parsing stdout,
|
|
6
|
+
every command emits clean JSON via `--json`.
|
|
7
|
+
|
|
8
|
+
## Why this shape (grounded in the backend, verified against source)
|
|
9
|
+
|
|
10
|
+
- **One contract.** OpenAPI is served at `/openapi/v1.json` (`Program.cs`:
|
|
11
|
+
`AddOpenApi()` + `MapOpenApi()`) — the same doc the UI's
|
|
12
|
+
`frontend/packages/api-client` and Bruno consume. The CLI generates its
|
|
13
|
+
client from it; there is no hand-maintained request/response surface.
|
|
14
|
+
- **The API is the real surface.** Every MCP tool is a thin shim over a
|
|
15
|
+
Wolverine handler that is *also* a `[WolverinePost]`/`[WolverineGet]` endpoint
|
|
16
|
+
carrying the same `[TenantPermission]`. The permission middleware is HTTP-only,
|
|
17
|
+
so the CLI inherits identical enforcement.
|
|
18
|
+
- **Tenant is mandatory.** `MapWolverineEndpoints` calls `TenantId.AssertExists()`
|
|
19
|
+
— a missing `tenant` header is a hard 400. The client sets it on every request.
|
|
20
|
+
- **Auth matches the AS.** `OAuthEndpointMappings.cs` exposes auth-code + PKCE
|
|
21
|
+
(`/oauth/authorize` -> `/oauth/token`), RFC 8414 discovery, and RFC 7591
|
|
22
|
+
dynamic client registration (`/oauth/register`). No device-code or
|
|
23
|
+
client-credentials grant — so the CLI uses the loopback auth-code + PKCE
|
|
24
|
+
pattern and self-registers via DCR, mirroring how Claude.ai web connects.
|
|
25
|
+
Headless/CI sets `SECHROOM_TOKEN`.
|
|
26
|
+
|
|
27
|
+
### Verified specifics that shaped the code
|
|
28
|
+
|
|
29
|
+
- **DCR `client_id` is deterministic** (`RegisterClientEndpoint.cs`):
|
|
30
|
+
`dyn-` + base64url(SHA256(sorted `redirect_uris`))[:22], idempotent on the
|
|
31
|
+
redirect set. PKCE-only, no secret. The CLI therefore registers a **fixed set
|
|
32
|
+
of candidate loopback ports** as the redirect_uris (stable set -> stable id)
|
|
33
|
+
and binds whichever is free — so re-login never mints a new client or stales
|
|
34
|
+
the cache.
|
|
35
|
+
- **PKCE is S256-only** (`TokenEndpoint.VerifyPkce`).
|
|
36
|
+
- **Refresh works** (`TokenEndpoint`): `grant_type=refresh_token` is a real
|
|
37
|
+
grant, and the token body returns a `refresh_token` for non-browser clients.
|
|
38
|
+
`requireToken()` refreshes automatically near expiry.
|
|
39
|
+
- **`POST /memories`** binds `CreateMemoryInput` — `Content` and `Confidence`
|
|
40
|
+
are required (MCP shim defaults `"{}"` / `1.0`); `Owner` is nullable (omit for
|
|
41
|
+
Unfiled). The `create` command applies those defaults.
|
|
42
|
+
- **`GET /memories/{memoryId}`** returns an `OperationsItem<MemoryReadModel>` envelope.
|
|
43
|
+
- **`POST /memories/search`** (`SearchMemoriesEndpoint`) — `SemanticQuery`
|
|
44
|
+
routes to the hybrid vector+FTS RRF ranker; the `search` command uses it.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# pre-GA / internal builds are published under the `next` dist-tag
|
|
50
|
+
npx @sechroom/cli@next login
|
|
51
|
+
npm i -g @sechroom/cli@next
|
|
52
|
+
|
|
53
|
+
# once GA (promoted to `latest`)
|
|
54
|
+
npx @sechroom/cli login
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Generate the client (once, and after any API change)
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# defaults to https://app.sechroom.ai/api/openapi/v1.json
|
|
61
|
+
npm run gen
|
|
62
|
+
SECHROOM_OPENAPI_URL=https://<host>/api/openapi/v1.json npm run gen # other envs
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`npm run gen` overwrites `src/generated/api.d.ts`. The **real generated types are
|
|
66
|
+
committed** (regenerated from the live prod spec) so builds are hermetic — no
|
|
67
|
+
live-API dependency at compile time; re-run `gen` after any API change. Generating
|
|
68
|
+
against the live schema is what caught the original placeholder's shape drift
|
|
69
|
+
(e.g. the work-log append body is `{bullet, laneId, workspaceId, title}`, and
|
|
70
|
+
`CreateMemoryInput`/`SearchInput` require several nullable fields present), now
|
|
71
|
+
fixed in the command bodies.
|
|
72
|
+
|
|
73
|
+
## Use
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
sechroom config set baseUrl https://app.sechroom.ai/api # prod (staging: https://staging.app.sechroom.ai/api)
|
|
77
|
+
sechroom config set tenant ocd
|
|
78
|
+
sechroom login
|
|
79
|
+
|
|
80
|
+
sechroom memory create --text "first note from the CLI" --type reference
|
|
81
|
+
sechroom memory get mem_XXXX
|
|
82
|
+
sechroom memory search "convention lifecycle drift" --limit 5
|
|
83
|
+
sechroom worklog append --text "shipped CLI skeleton; pointers: ..." --source claude-code-chris
|
|
84
|
+
|
|
85
|
+
sechroom --json memory get mem_XXXX # agent-friendly
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Headless:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
export SECHROOM_TOKEN="<jwt from /auth/dev/token>"
|
|
92
|
+
export SECHROOM_TENANT=ocd
|
|
93
|
+
sechroom --json memory search "rate limiting"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Layout
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
src/
|
|
100
|
+
index.ts commander root + global flags + login/config
|
|
101
|
+
auth.ts OAuth auth-code+PKCE loopback (fixed-port DCR) + refresh + cache
|
|
102
|
+
client.ts openapi-fetch client (auth + tenant middleware) + emit/fail
|
|
103
|
+
config.ts base-url / tenant / token resolution + persistence
|
|
104
|
+
generated/api.d.ts typed client — `npm run gen`; real types committed (hermetic)
|
|
105
|
+
commands/
|
|
106
|
+
memory.ts create / get / search
|
|
107
|
+
worklog.ts append
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Remaining before ship
|
|
111
|
+
|
|
112
|
+
- **Provision the npm publish credential** (the only blocker): create the
|
|
113
|
+
`@sechroom` npmjs org + an Automation token and paste it into the
|
|
114
|
+
`Sechroom_PublishCli` build config's `NPM_TOKEN` param. See
|
|
115
|
+
[`ci/PUBLISHING.md`](./ci/PUBLISHING.md).
|
|
116
|
+
- Decide the registered candidate-port set (`CANDIDATE_PORTS` in `auth.ts`) —
|
|
117
|
+
three localhost ports today; keep them stable, since the DCR client id is a
|
|
118
|
+
function of that set.
|
|
119
|
+
|
|
120
|
+
## Distribution (initial)
|
|
121
|
+
|
|
122
|
+
Public **npmjs** under the `@sechroom` org is the host; **TeamCity** is the
|
|
123
|
+
publisher (build config `Sechroom_PublishCli`). The full pipeline — steps,
|
|
124
|
+
parameters, npm auth, provisioning, and how to run a publish — is documented in
|
|
125
|
+
[`ci/PUBLISHING.md`](./ci/PUBLISHING.md). In short: a manual-trigger config runs
|
|
126
|
+
`npm ci → gen → typecheck → build → publish` in a `node:20` container, authed by
|
|
127
|
+
a masked `NPM_TOKEN` param; `next` stamps a per-build prerelease, `latest` ships
|
|
128
|
+
`BASE_CLI_VERSION`.
|
|
129
|
+
|
|
130
|
+
Why public npmjs: the CLI is customer-facing, and `npx @sechroom/cli` must work
|
|
131
|
+
with zero `.npmrc`/token on the customer side. GitHub Packages or a private
|
|
132
|
+
registry would force every external adopter to authenticate just to install.
|
|
133
|
+
|
|
134
|
+
Pure Node — no runtime prerequisite beyond Node 20. If a zero-dependency single
|
|
135
|
+
binary is later preferred, the alternative is per-platform AOT binaries published
|
|
136
|
+
as npm `optionalDependencies` behind a launcher shim (the esbuild/biome pattern),
|
|
137
|
+
at the cost of an OS×arch build matrix. This (plain Node) keeps install friction
|
|
138
|
+
lowest.
|
|
139
|
+
|
|
140
|
+
## Onboarding hook
|
|
141
|
+
|
|
142
|
+
Parallels `BuildMcpConfigSection` in `OperatorSurfaceDescriptors`: a
|
|
143
|
+
`BuildCliConfigSection` can emit the `npx @sechroom/cli` invocation + the
|
|
144
|
+
`config set` bootstrap the same way the MCP config is surfaced today.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/auth.ts
|
|
7
|
+
import { createServer } from "http";
|
|
8
|
+
import { randomBytes, createHash } from "crypto";
|
|
9
|
+
import open from "open";
|
|
10
|
+
|
|
11
|
+
// src/config.ts
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "fs";
|
|
15
|
+
var CONFIG_DIR = join(homedir(), ".config", "sechroom");
|
|
16
|
+
var CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
17
|
+
var TOKEN_FILE = join(CONFIG_DIR, "token.json");
|
|
18
|
+
var DEFAULT_BASE_URL = "https://app.sechroom.ai/api";
|
|
19
|
+
function ensureDir() {
|
|
20
|
+
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
21
|
+
}
|
|
22
|
+
function readPersisted() {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
|
|
25
|
+
} catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function writePersisted(patch) {
|
|
30
|
+
ensureDir();
|
|
31
|
+
const next = { ...readPersisted(), ...patch };
|
|
32
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2), { mode: 384 });
|
|
33
|
+
}
|
|
34
|
+
function readToken() {
|
|
35
|
+
const envTok = process.env.SECHROOM_TOKEN;
|
|
36
|
+
if (envTok) return { accessToken: envTok };
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(TOKEN_FILE, "utf8"));
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function writeToken(tok) {
|
|
44
|
+
ensureDir();
|
|
45
|
+
writeFileSync(TOKEN_FILE, JSON.stringify(tok, null, 2), { mode: 384 });
|
|
46
|
+
}
|
|
47
|
+
function resolveConfig(flags) {
|
|
48
|
+
const persisted = readPersisted();
|
|
49
|
+
const baseUrl = flags.baseUrl ?? process.env.SECHROOM_BASE_URL ?? persisted.baseUrl ?? DEFAULT_BASE_URL;
|
|
50
|
+
const tenant = flags.tenant ?? process.env.SECHROOM_TENANT ?? persisted.tenant ?? "";
|
|
51
|
+
if (!tenant) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
"No tenant set. The Sechroom API rejects untenanted requests (HTTP 400). Pass --tenant <id>, set SECHROOM_TENANT, or run `sechroom config set tenant <id>`."
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return { baseUrl: baseUrl.replace(/\/$/, ""), tenant, clientId: persisted.clientId };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/auth.ts
|
|
60
|
+
var SCOPES = "openid email profile";
|
|
61
|
+
var CANDIDATE_PORTS = [51787, 51788, 51789];
|
|
62
|
+
var REDIRECT_PATH = "/callback";
|
|
63
|
+
function b64url(buf) {
|
|
64
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
65
|
+
}
|
|
66
|
+
function redirectUriFor(port) {
|
|
67
|
+
return `http://127.0.0.1:${port}${REDIRECT_PATH}`;
|
|
68
|
+
}
|
|
69
|
+
async function discover(baseUrl) {
|
|
70
|
+
const res = await fetch(`${baseUrl}/.well-known/oauth-authorization-server`);
|
|
71
|
+
if (!res.ok) throw new Error(`AS discovery failed (${res.status}) at ${baseUrl}`);
|
|
72
|
+
return await res.json();
|
|
73
|
+
}
|
|
74
|
+
async function ensureClientId(meta) {
|
|
75
|
+
const cached = readPersisted().clientId;
|
|
76
|
+
if (cached) return cached;
|
|
77
|
+
if (!meta.registration_endpoint) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
"Server advertises no registration_endpoint and no client_id is cached. Pre-register a client and run `sechroom config set clientId <id>`."
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
const res = await fetch(meta.registration_endpoint, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "content-type": "application/json" },
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
client_name: "sechroom-cli",
|
|
87
|
+
redirect_uris: CANDIDATE_PORTS.map(redirectUriFor)
|
|
88
|
+
})
|
|
89
|
+
});
|
|
90
|
+
if (!res.ok) throw new Error(`Dynamic client registration failed (${res.status}): ${await res.text()}`);
|
|
91
|
+
const reg = await res.json();
|
|
92
|
+
writePersisted({ clientId: reg.client_id });
|
|
93
|
+
return reg.client_id;
|
|
94
|
+
}
|
|
95
|
+
function startLoopback(state) {
|
|
96
|
+
return new Promise((resolveOuter, rejectOuter) => {
|
|
97
|
+
const tryPort = (i) => {
|
|
98
|
+
if (i >= CANDIDATE_PORTS.length) {
|
|
99
|
+
rejectOuter(new Error(`All loopback ports busy (${CANDIDATE_PORTS.join(", ")}). Close the conflicting process and retry.`));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const port = CANDIDATE_PORTS[i];
|
|
103
|
+
let resolveCode;
|
|
104
|
+
let rejectCode;
|
|
105
|
+
const code = new Promise((res, rej) => {
|
|
106
|
+
resolveCode = res;
|
|
107
|
+
rejectCode = rej;
|
|
108
|
+
});
|
|
109
|
+
const server = createServer((req, res) => {
|
|
110
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
111
|
+
if (!url.pathname.startsWith(REDIRECT_PATH)) {
|
|
112
|
+
res.writeHead(404).end();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const got = url.searchParams.get("code");
|
|
116
|
+
const gotState = url.searchParams.get("state");
|
|
117
|
+
const err = url.searchParams.get("error");
|
|
118
|
+
res.writeHead(err ? 400 : 200, { "content-type": "text/html" });
|
|
119
|
+
res.end(
|
|
120
|
+
err ? `<h3>Authorization failed: ${err}</h3>` : "<h3>Signed in to Sechroom.</h3><p>Return to the terminal.</p>"
|
|
121
|
+
);
|
|
122
|
+
server.close();
|
|
123
|
+
if (err) return rejectCode(new Error(`Authorization error: ${err}`));
|
|
124
|
+
if (!got) return rejectCode(new Error("No code in callback."));
|
|
125
|
+
if (gotState !== state) return rejectCode(new Error("State mismatch \u2014 possible CSRF."));
|
|
126
|
+
resolveCode(got);
|
|
127
|
+
});
|
|
128
|
+
server.on("error", (e) => {
|
|
129
|
+
if (e.code === "EADDRINUSE") tryPort(i + 1);
|
|
130
|
+
else rejectOuter(e);
|
|
131
|
+
});
|
|
132
|
+
server.listen(port, "127.0.0.1", () => {
|
|
133
|
+
resolveOuter({ port, redirectUri: redirectUriFor(port), code });
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
tryPort(0);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async function exchange(meta, clientId, code, verifier, redirectUri) {
|
|
140
|
+
const res = await fetch(meta.token_endpoint, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
143
|
+
body: new URLSearchParams({
|
|
144
|
+
grant_type: "authorization_code",
|
|
145
|
+
code,
|
|
146
|
+
redirect_uri: redirectUri,
|
|
147
|
+
client_id: clientId,
|
|
148
|
+
code_verifier: verifier
|
|
149
|
+
})
|
|
150
|
+
});
|
|
151
|
+
if (!res.ok) throw new Error(`Token exchange failed (${res.status}): ${await res.text()}`);
|
|
152
|
+
persistTokenResponse(await res.json());
|
|
153
|
+
}
|
|
154
|
+
function persistTokenResponse(json) {
|
|
155
|
+
const tok = json;
|
|
156
|
+
writeToken({
|
|
157
|
+
accessToken: tok.access_token,
|
|
158
|
+
refreshToken: tok.refresh_token,
|
|
159
|
+
expiresAt: tok.expires_in ? Date.now() + tok.expires_in * 1e3 : void 0
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
async function login(cfg) {
|
|
163
|
+
const meta = await discover(cfg.baseUrl);
|
|
164
|
+
const clientId = await ensureClientId(meta);
|
|
165
|
+
const state = b64url(randomBytes(16));
|
|
166
|
+
const verifier = b64url(randomBytes(32));
|
|
167
|
+
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
|
168
|
+
const loopback = await startLoopback(state);
|
|
169
|
+
const authUrl = new URL(meta.authorization_endpoint);
|
|
170
|
+
authUrl.searchParams.set("response_type", "code");
|
|
171
|
+
authUrl.searchParams.set("client_id", clientId);
|
|
172
|
+
authUrl.searchParams.set("redirect_uri", loopback.redirectUri);
|
|
173
|
+
authUrl.searchParams.set("scope", SCOPES);
|
|
174
|
+
authUrl.searchParams.set("state", state);
|
|
175
|
+
authUrl.searchParams.set("code_challenge", challenge);
|
|
176
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
177
|
+
process.stderr.write(`Opening browser for sign-in...
|
|
178
|
+
If it does not open, visit:
|
|
179
|
+
${authUrl}
|
|
180
|
+
`);
|
|
181
|
+
await open(authUrl.toString());
|
|
182
|
+
const code = await loopback.code;
|
|
183
|
+
await exchange(meta, clientId, code, verifier, loopback.redirectUri);
|
|
184
|
+
process.stderr.write("Signed in. Token cached.\n");
|
|
185
|
+
}
|
|
186
|
+
async function requireToken(cfg) {
|
|
187
|
+
if (process.env.SECHROOM_TOKEN) return process.env.SECHROOM_TOKEN;
|
|
188
|
+
const cached = readToken();
|
|
189
|
+
if (!cached?.accessToken) {
|
|
190
|
+
throw new Error("Not signed in. Run `sechroom login` (or set SECHROOM_TOKEN for headless use).");
|
|
191
|
+
}
|
|
192
|
+
const nearExpiry = cached.expiresAt !== void 0 && Date.now() > cached.expiresAt - 6e4;
|
|
193
|
+
if (nearExpiry && cached.refreshToken) {
|
|
194
|
+
const meta = await discover(cfg.baseUrl);
|
|
195
|
+
const res = await fetch(meta.token_endpoint, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
198
|
+
body: new URLSearchParams({
|
|
199
|
+
grant_type: "refresh_token",
|
|
200
|
+
refresh_token: cached.refreshToken
|
|
201
|
+
})
|
|
202
|
+
});
|
|
203
|
+
if (res.ok) {
|
|
204
|
+
persistTokenResponse(await res.json());
|
|
205
|
+
return readToken().accessToken;
|
|
206
|
+
}
|
|
207
|
+
throw new Error("Session expired and refresh failed. Run `sechroom login` again.");
|
|
208
|
+
}
|
|
209
|
+
return cached.accessToken;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// src/client.ts
|
|
213
|
+
import createClient from "openapi-fetch";
|
|
214
|
+
async function makeClient(cfg) {
|
|
215
|
+
const token = await requireToken(cfg);
|
|
216
|
+
const client = createClient({ baseUrl: cfg.baseUrl });
|
|
217
|
+
client.use({
|
|
218
|
+
onRequest({ request }) {
|
|
219
|
+
request.headers.set("authorization", `Bearer ${token}`);
|
|
220
|
+
request.headers.set("tenant", cfg.tenant);
|
|
221
|
+
return request;
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
return client;
|
|
225
|
+
}
|
|
226
|
+
function emit(data, json) {
|
|
227
|
+
if (json) {
|
|
228
|
+
process.stdout.write(JSON.stringify(data) + "\n");
|
|
229
|
+
} else {
|
|
230
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function fail(error) {
|
|
234
|
+
const msg = typeof error === "object" && error !== null && "title" in error ? String(error.title) : typeof error === "object" ? JSON.stringify(error) : String(error);
|
|
235
|
+
process.stderr.write(`error: ${msg}
|
|
236
|
+
`);
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// src/commands/memory.ts
|
|
241
|
+
function registerMemory(program2) {
|
|
242
|
+
const memory = program2.command("memory").description("Create, read, and search memories");
|
|
243
|
+
memory.command("create").description("Create a memory (POST /memories)").requiredOption("--text <text>", "Memory body text").option("--type <type>", "Memory type", "reference").option("--title <title>", "Optional title").option("--tag <tag...>", "Tags (repeatable)").option("--owner-type <ownerType>", "Workspace | Project | Unfiled", "Unfiled").option("--owner-id <ownerId>", "Owner id (required for Workspace/Project)").option("--source <source>", "Source / lane stamp", "cli").option("--confidence <n>", "Confidence 0..1", "1.0").action(async (opts, cmd) => {
|
|
244
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
245
|
+
const client = await makeClient(cfg);
|
|
246
|
+
const unfiled = String(opts.ownerType).toLowerCase() === "unfiled";
|
|
247
|
+
const { data, error } = await client.POST("/memories", {
|
|
248
|
+
body: {
|
|
249
|
+
text: opts.text,
|
|
250
|
+
type: opts.type,
|
|
251
|
+
content: "{}",
|
|
252
|
+
confidence: Number(opts.confidence),
|
|
253
|
+
source: opts.source,
|
|
254
|
+
archetype: "Document",
|
|
255
|
+
title: opts.title ?? null,
|
|
256
|
+
tags: opts.tag ?? null,
|
|
257
|
+
owner: unfiled ? null : { type: opts.ownerType, id: String(opts.ownerId ?? "") }
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
if (error) fail(error);
|
|
261
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
262
|
+
});
|
|
263
|
+
memory.command("get <memoryId>").description("Fetch a memory by id (GET /memories/{memoryId})").action(async (memoryId, _opts, cmd) => {
|
|
264
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
265
|
+
const client = await makeClient(cfg);
|
|
266
|
+
const { data, error } = await client.GET("/memories/{memoryId}", {
|
|
267
|
+
params: { path: { memoryId } }
|
|
268
|
+
});
|
|
269
|
+
if (error) fail(error);
|
|
270
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
271
|
+
});
|
|
272
|
+
memory.command("search <query>").description("Hybrid search (POST /memories/search; SemanticQuery -> vector+FTS RRF)").option("--limit <n>", "Max results", "10").option("--tag <tag...>", "Require all listed tags").option("--workspace <workspaceId>", "Scope to a workspace (cascades to its projects)").option("--include-archived", "Include archived memories", false).action(async (query, opts, cmd) => {
|
|
273
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
274
|
+
const client = await makeClient(cfg);
|
|
275
|
+
const { data, error } = await client.POST("/memories/search", {
|
|
276
|
+
body: {
|
|
277
|
+
query: null,
|
|
278
|
+
textQuery: null,
|
|
279
|
+
semanticQuery: query,
|
|
280
|
+
hybrid: true,
|
|
281
|
+
limit: Number(opts.limit),
|
|
282
|
+
includeArchived: Boolean(opts.includeArchived),
|
|
283
|
+
includeSystem: false,
|
|
284
|
+
...opts.tag ? { tags: opts.tag } : {},
|
|
285
|
+
...opts.workspace ? { workspaceId: opts.workspace } : {}
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
if (error) fail(error);
|
|
289
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// src/commands/worklog.ts
|
|
294
|
+
function registerWorklog(program2) {
|
|
295
|
+
const worklog = program2.command("worklog").description("Append to the daily work log");
|
|
296
|
+
worklog.command("append").description("Append a work-log entry (POST /operator-surface/work-log/append)").requiredOption("--text <text>", "Entry body (short bullets / pointers) \u2014 the bullet").option("--source <source>", "Lane stamp (e.g. claude-code-chris) \u2014 laneId", "cli").option("--workspace <workspaceId>", "Target work-log workspace (default: caller's daily log)").option("--title <title>", "Optional entry title").action(async (opts, cmd) => {
|
|
297
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
298
|
+
const client = await makeClient(cfg);
|
|
299
|
+
const { data, error } = await client.POST("/operator-surface/work-log/append", {
|
|
300
|
+
body: {
|
|
301
|
+
bullet: opts.text,
|
|
302
|
+
laneId: opts.source ?? null,
|
|
303
|
+
workspaceId: opts.workspace ?? null,
|
|
304
|
+
title: opts.title ?? null
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
if (error) fail(error);
|
|
308
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// src/index.ts
|
|
313
|
+
var program = new Command();
|
|
314
|
+
program.name("sechroom").description("Sechroom CLI \u2014 thin generated client over the Sechroom HTTP API. An agent/human surface alongside MCP.").version("0.0.0").option("--base-url <url>", "API base URL (overrides config / SECHROOM_BASE_URL)").option("--tenant <tenant>", "Tenant id (required by the API; overrides config / SECHROOM_TENANT)").option("--json", "Emit compact JSON (for scripts and agents)", false);
|
|
315
|
+
program.command("login").description("Sign in via browser (OAuth auth-code + PKCE, dynamic client registration)").action(async (_opts, cmd) => {
|
|
316
|
+
const g = cmd.optsWithGlobals();
|
|
317
|
+
const persisted = readPersisted();
|
|
318
|
+
const baseUrl = g.baseUrl ?? process.env.SECHROOM_BASE_URL ?? persisted.baseUrl ?? "https://app.sechroom.ai/api";
|
|
319
|
+
await login({ baseUrl: baseUrl.replace(/\/$/, ""), tenant: g.tenant ?? "" });
|
|
320
|
+
});
|
|
321
|
+
var config = program.command("config").description("Manage persisted CLI config");
|
|
322
|
+
config.command("set <key> <value>").description("Set baseUrl | tenant | clientId").action((key, value) => {
|
|
323
|
+
if (!["baseUrl", "tenant", "clientId"].includes(key)) {
|
|
324
|
+
process.stderr.write(`unknown key: ${key} (expected baseUrl | tenant | clientId)
|
|
325
|
+
`);
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
writePersisted({ [key]: value });
|
|
329
|
+
process.stdout.write(`set ${key}
|
|
330
|
+
`);
|
|
331
|
+
});
|
|
332
|
+
config.command("show").description("Print persisted config").action(() => {
|
|
333
|
+
process.stdout.write(JSON.stringify(readPersisted(), null, 2) + "\n");
|
|
334
|
+
});
|
|
335
|
+
registerMemory(program);
|
|
336
|
+
registerWorklog(program);
|
|
337
|
+
program.parseAsync().catch((err) => {
|
|
338
|
+
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}
|
|
339
|
+
`);
|
|
340
|
+
process.exit(1);
|
|
341
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sechroom/cli",
|
|
3
|
+
"version": "2026.6.1",
|
|
4
|
+
"description": "Sechroom CLI — a thin, generated client over the Sechroom HTTP API. An agent/human surface alongside MCP.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"author": "OCD",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/OcdLimited/sechroom.git",
|
|
11
|
+
"directory": "tools/sechroom-cli"
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"sechroom": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public",
|
|
24
|
+
"registry": "https://registry.npmjs.org/"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"gen": "openapi-typescript \"${SECHROOM_OPENAPI_URL:-https://app.sechroom.ai/api/openapi/v1.json}\" -o src/generated/api.d.ts",
|
|
28
|
+
"build": "tsup src/index.ts --format esm --target node20 --clean",
|
|
29
|
+
"dev": "tsx src/index.ts",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"prepublishOnly": "npm run build"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"commander": "^12.1.0",
|
|
35
|
+
"open": "^10.1.0",
|
|
36
|
+
"openapi-fetch": "^0.13.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^20.14.0",
|
|
40
|
+
"openapi-typescript": "^7.4.0",
|
|
41
|
+
"tsup": "^8.3.0",
|
|
42
|
+
"tsx": "^4.19.0",
|
|
43
|
+
"typescript": "^5.6.0"
|
|
44
|
+
}
|
|
45
|
+
}
|