sekura 0.1.1 → 0.2.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 CHANGED
@@ -6,25 +6,36 @@ Connect your coding tool to [Sekura](https://sekura.ai) in one command.
6
6
  npx sekura@latest init
7
7
  ```
8
8
 
9
- Sekura is an AI red team as a service: it scans a GitHub repository you have granted it, exploits what it finds to prove each finding, and delivers a report. Its MCP server is remote, at `https://sekura.ai/mcp`, so this package runs no server and holds no credential. It writes that address into the coding tools on your machine, installs a skill that teaches the agent the quote-then-confirm rule, and gets out of the way.
9
+ Sekura is an AI red team as a service: it scans a GitHub repository you have granted it, exploits what it finds to prove each finding, and delivers a report. Its MCP server is remote, at `https://sekura.ai/mcp`, so this package runs no server. It writes that address into the coding tools on your machine and installs a skill that teaches the agent the quote-then-confirm rule. Then it finishes getting started right in the terminal: it signs you in once, helps you install the GitHub App, and lets you pick a repository. When you open your coding tool, Sekura already works.
10
10
 
11
11
  ## What `init` does
12
12
 
13
13
  1. **Detects** the tools on this machine: Claude Code, Cursor, GitHub Copilot in VS Code, OpenAI Codex, Kiro, Cline, Zed, Antigravity, OpenCode, JetBrains Junie, Factory Droid, Tabnine, Devin and Goose.
14
14
  2. **Asks** which to connect, and whether for every project (your user configuration) or this project only.
15
- 3. **Shows the plan** before anything is written: each file and the exact entry, or the tool's own command.
16
- 4. **Applies** it, one step at a time, and says what each did. Files that are not plain JSON (Zed's `settings.json` with comments, for one) are never rewritten; you are shown the lines to add instead.
17
- 5. **Checks** that the Sekura server answers, and tells you how to sign in from each tool.
18
-
19
- Signing in happens in the tool: the first time it uses Sekura, a sekura.ai page opens in your browser. Sign in with GitHub and approve. Nothing to copy.
15
+ 3. **Signs you in** once, in your browser. You sign in with GitHub on sekura.ai and approve, and the CLI receives a Sekura key and gives it to each tool, so none of them asks you to sign in. Codex and Devin can't take a key in their configuration, so the CLI runs their own `mcp login` for you; since your browser is already signed in, that's one click.
16
+ 4. **Shows the plan** before anything is written: each file and the exact entry (with the key shown as `sek_live_…abcd`), or the tool's own command.
17
+ 5. **Applies** it, one step at a time, and says what each did. Files that are not plain JSON (Zed's `settings.json` with comments, for one) are never rewritten; you are shown the lines to add instead.
18
+ 6. **Checks** that the Sekura server answers.
19
+ 7. **Gets you to a first scan**:
20
+ - It says what your account still needs (the Terms, or email verification).
21
+ - It offers to install the Sekura GitHub App when nothing can be scanned yet, or when the repository in the current folder isn't covered.
22
+ - It lets you pick a repository, with the current folder's first.
23
+ - If your free first scan is still available, it opens sekura.ai so you can start it there.
24
+ 8. **Tells you what to type** in each tool, such as `/sekura scan owner/repo` in Claude Code (`/sekura help` lists every command).
25
+
26
+ Skip the sign-in with `--no-login`; each tool then opens a sekura.ai sign-in page the first time it uses Sekura. Project scope never writes the key, because project files get committed.
20
27
 
21
28
  ```bash
22
29
  npx sekura@latest init --yes # every detected tool, user scope, with the skill, no questions
23
30
  npx sekura@latest init --dry-run # show the plan and change nothing
24
31
  npx sekura@latest init --scope project # this project only: .mcp.json, .cursor/mcp.json, AGENTS.md …
25
32
  npx sekura@latest init --tools claude,cursor --no-skill
26
- npx sekura@latest status # what is connected
27
- npx sekura@latest uninstall # remove the entries, the skill and the guidance block
33
+ npx sekura@latest init --no-login # no sign-in here; each tool signs in on first use
34
+ npx sekura@latest login # sign in again, give connected tools the key, pick a repository
35
+ npx sekura@latest status # what is connected, and who is signed in
36
+ npx sekura@latest logout # forget the saved key
37
+ npx sekura@latest uninstall # remove the entries, the skill, the guidance block and the saved key
38
+ SEKURA_API_KEY=sek_live_… npx sekura@latest init --yes # unattended, with a key from Settings
28
39
  ```
29
40
 
30
41
  ## What it writes
@@ -49,8 +60,11 @@ The skill goes to `~/.agents/skills/sekura/SKILL.md` and, when Claude Code is pr
49
60
  ## What stays untouched
50
61
 
51
62
  - Every other server, setting and file. The installer sets one key, `sekura`, and reads nothing else.
52
- - Your source code. Nothing is read, uploaded or committed.
53
- - Your credentials. There is no login step and no token on disk; the sign-in is the tool's own OAuth flow with sekura.ai.
63
+ - Your source code isn't read or uploaded by this installer.
64
+
65
+ ## Your key
66
+
67
+ Signing in creates one Sekura key, named "Sekura CLI (your computer's name)". You can see it and revoke it at [sekura.ai/app/settings](https://sekura.ai/app/settings) → Agents and API keys. The CLI saves it in `~/.config/sekura/credentials.json`, readable by you only, and writes it as an `Authorization: Bearer` header into each tool's user configuration. `sekura uninstall` removes both, and `sekura logout` removes the saved copy. Revoking the key in Settings ends it everywhere; run `sekura login` to get a new one.
54
68
 
55
69
  Review before running: the source is this repository, the package is [`sekura` on npm](https://www.npmjs.com/package/sekura), and `--dry-run` prints every write the wizard would make.
56
70
 
package/dist/api.js ADDED
@@ -0,0 +1,42 @@
1
+ // The few Sekura tools the CLI calls itself, over the same MCP endpoint the
2
+ // coding tools use. The server is stateless JSON-RPC over POST and answers
3
+ // in plain JSON, so no session and no MCP library: one request per call.
4
+ // Only tools that read are called here; every scan is still quoted and
5
+ // agreed in the coding tool, or started by the person on sekura.ai.
6
+ import { ENDPOINT } from './catalogue.js';
7
+ export class SignedOut extends Error {
8
+ constructor(detail) { super(detail); this.name = 'SignedOut'; }
9
+ }
10
+ let nextId = 1;
11
+ export async function callTool(key, name, args = {}) {
12
+ const r = await fetch(ENDPOINT, {
13
+ method: 'POST',
14
+ headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
15
+ body: JSON.stringify({ jsonrpc: '2.0', id: nextId++, method: 'tools/call', params: { name, arguments: args } }),
16
+ signal: AbortSignal.timeout(30_000),
17
+ });
18
+ const body = (await r.json().catch(() => null));
19
+ if (r.status === 401)
20
+ throw new SignedOut(body?.error_description || 'the key is no longer valid');
21
+ if (!r.ok || !body)
22
+ throw new Error(`Sekura answered ${r.status} to ${name}${body?.error_description ? `: ${body.error_description}` : ''}`);
23
+ if (body.error)
24
+ throw new Error(`Sekura ${name}: ${body.error.message ?? JSON.stringify(body.error)}`);
25
+ const result = body.result ?? {};
26
+ if (result.isError)
27
+ throw new Error(`Sekura ${name}: ${result.content?.[0]?.text ?? 'failed'}`);
28
+ if (result.structuredContent)
29
+ return result.structuredContent;
30
+ // Older answers carry the data as JSON in the second text block.
31
+ for (const c of result.content ?? []) {
32
+ if (c?.type !== 'text')
33
+ continue;
34
+ try {
35
+ return JSON.parse(c.text);
36
+ }
37
+ catch { /* the prose block */ }
38
+ }
39
+ throw new Error(`Sekura ${name}: no data in the answer`);
40
+ }
41
+ export const getAccount = (key) => callTool(key, 'getAccount');
42
+ export const listRepositories = (key) => callTool(key, 'listRepositories');
package/dist/auth.js ADDED
@@ -0,0 +1,172 @@
1
+ // Signing in to Sekura from the terminal, once, so the tools do not have to.
2
+ //
3
+ // OAuth 2.1 against sekura.ai, the way the tools themselves do it: a client
4
+ // registered on the spot (public, no secret), PKCE S256, and a loopback
5
+ // redirect on a port picked per run (RFC 8252; sekura.ai accepts any port
6
+ // on 127.0.0.1 for a client that registered a loopback URI). What comes back
7
+ // is an ordinary `sek_live_` key with no expiry, listed and revocable under
8
+ // Settings → Agents and API keys, bound to the MCP address (RFC 8707).
9
+ //
10
+ // The key is kept in ~/.config/sekura/credentials.json, readable by the
11
+ // person only, so a rerun of `init` or `login` does not sign in again.
12
+ // SEKURA_API_KEY in the environment wins over the file.
13
+ import { createHash, randomBytes } from 'node:crypto';
14
+ import { createServer } from 'node:http';
15
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
16
+ import { hostname, platform } from 'node:os';
17
+ import { dirname, join } from 'node:path';
18
+ import { spawn } from 'node:child_process';
19
+ import { ENDPOINT, home } from './catalogue.js';
20
+ export const ISSUER = new URL(ENDPOINT).origin;
21
+ export const credentialsPath = () => join(process.env.XDG_CONFIG_HOME || join(home(), '.config'), 'sekura', 'credentials.json');
22
+ export function readKey() {
23
+ const env = process.env.SEKURA_API_KEY?.trim();
24
+ if (env)
25
+ return env;
26
+ try {
27
+ const c = JSON.parse(readFileSync(credentialsPath(), 'utf8'));
28
+ return typeof c.key === 'string' && c.key ? c.key : null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ export function saveKey(key, email) {
35
+ const p = credentialsPath();
36
+ mkdirSync(dirname(p), { recursive: true });
37
+ writeFileSync(p, JSON.stringify({ key, ...(email ? { email } : {}), createdAt: new Date().toISOString() }, null, 2) + '\n', { mode: 0o600 });
38
+ try {
39
+ chmodSync(p, 0o600);
40
+ }
41
+ catch { /* Windows */ }
42
+ return p;
43
+ }
44
+ export function forgetKey() {
45
+ const p = credentialsPath();
46
+ if (!existsSync(p))
47
+ return false;
48
+ rmSync(p);
49
+ return true;
50
+ }
51
+ /** A key as it may be shown: the prefix and the last four characters. */
52
+ export function redact(key) {
53
+ const m = /^(sek_[a-z]+_)/.exec(key);
54
+ return `${m ? m[1] : ''}…${key.slice(-4)}`;
55
+ }
56
+ /** Every key in a piece of text, redacted. */
57
+ export const redactAll = (s) => s.replace(/sek_[a-z]+_[A-Za-z0-9]+/g, (k) => redact(k));
58
+ // ── PKCE ───────────────────────────────────────────────────────────────────
59
+ const b64url = (b) => b.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
60
+ export function pkce() {
61
+ const verifier = b64url(randomBytes(32));
62
+ return { verifier, challenge: b64url(createHash('sha256').update(verifier).digest()) };
63
+ }
64
+ // ── the browser ────────────────────────────────────────────────────────────
65
+ /** Opens a URL in the default browser; false when there is none to ask. */
66
+ export function openBrowser(url) {
67
+ const [cmd, args] = platform() === 'darwin' ? ['open', [url]]
68
+ : platform() === 'win32' ? ['cmd', ['/c', 'start', '""', url.replace(/&/g, '^&')]]
69
+ : ['xdg-open', [url]];
70
+ try {
71
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
72
+ child.on('error', () => { });
73
+ child.unref();
74
+ return true;
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ // ── the loopback callback ──────────────────────────────────────────────────
81
+ const PAGE = (title, body) => `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font:16px system-ui;max-width:32em;margin:4em auto;padding:0 1em"><h1 style="font-size:1.4em">${title}</h1><p>${body}</p>`;
82
+ /** What one request to the callback means, given the state this run sent. */
83
+ export function readCallback(url, state) {
84
+ if (url.pathname !== '/callback')
85
+ return null;
86
+ if (url.searchParams.get('state') !== state)
87
+ return { error: 'the sign-in came back with another state; start again' };
88
+ const iss = url.searchParams.get('iss');
89
+ if (iss !== null && iss !== ISSUER)
90
+ return { error: `the sign-in came back from ${iss}, not ${ISSUER}` };
91
+ const err = url.searchParams.get('error');
92
+ if (err)
93
+ return { error: url.searchParams.get('error_description') || err };
94
+ const code = url.searchParams.get('code');
95
+ return code ? { code } : { error: 'the sign-in came back without a code' };
96
+ }
97
+ /** Listens on 127.0.0.1, on a port the system picks, for one callback. */
98
+ export async function listen(state, timeoutMs) {
99
+ let settle;
100
+ const result = new Promise((r) => { settle = r; });
101
+ const server = createServer((req, res) => {
102
+ const r = readCallback(new URL(req.url ?? '/', 'http://127.0.0.1'), state);
103
+ if (!r) {
104
+ res.writeHead(404).end();
105
+ return;
106
+ }
107
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
108
+ res.end('code' in r
109
+ ? PAGE('Signed in to Sekura', 'You can close this tab and go back to the terminal.')
110
+ : PAGE('Sekura could not sign you in', r.error.replace(/</g, '&lt;')));
111
+ settle(r);
112
+ });
113
+ await new Promise((ok, fail) => { server.once('error', fail); server.listen(0, '127.0.0.1', () => ok()); });
114
+ const port = server.address().port;
115
+ const timer = setTimeout(() => settle({ error: 'no answer from the browser in time' }), timeoutMs);
116
+ timer.unref();
117
+ const close = () => { clearTimeout(timer); server.close(); server.closeAllConnections?.(); };
118
+ return { redirectUri: `http://127.0.0.1:${port}/callback`, result, close };
119
+ }
120
+ // ── the flow ───────────────────────────────────────────────────────────────
121
+ async function post(path, body, type) {
122
+ const r = await fetch(`${ISSUER}${path}`, { method: 'POST', headers: { 'content-type': type, accept: 'application/json' }, body });
123
+ const data = (await r.json().catch(() => ({})));
124
+ if (!r.ok)
125
+ throw new Error(`sekura.ai refused the sign-in (${path}): ${data.error_description || data.error || r.status}`);
126
+ return data;
127
+ }
128
+ /**
129
+ * Signs in in the browser and returns the key. `onUrl` is told the address
130
+ * before the browser is asked to open it, so it can be printed for a machine
131
+ * with no browser to open.
132
+ */
133
+ export async function signIn(opts = {}) {
134
+ const state = b64url(randomBytes(16));
135
+ const { verifier, challenge } = pkce();
136
+ const cb = await listen(state, opts.timeoutMs ?? 300_000);
137
+ try {
138
+ const reg = await post('/oauth/register', JSON.stringify({
139
+ client_name: `Sekura CLI (${hostname().slice(0, 60)})`,
140
+ redirect_uris: ['http://127.0.0.1/callback'],
141
+ token_endpoint_auth_method: 'none',
142
+ grant_types: ['authorization_code'],
143
+ response_types: ['code'],
144
+ }), 'application/json');
145
+ const clientId = String(reg.client_id ?? '');
146
+ if (!clientId)
147
+ throw new Error('sekura.ai did not register the CLI');
148
+ const url = new URL(`${ISSUER}/oauth/authorize`);
149
+ for (const [k, v] of Object.entries({
150
+ response_type: 'code', client_id: clientId, redirect_uri: cb.redirectUri, state,
151
+ code_challenge: challenge, code_challenge_method: 'S256', scope: 'read write', resource: ENDPOINT,
152
+ }))
153
+ url.searchParams.set(k, v);
154
+ opts.onUrl?.(url.href);
155
+ if (opts.open !== false)
156
+ openBrowser(url.href);
157
+ const r = await cb.result;
158
+ if ('error' in r)
159
+ throw new Error(`Sekura sign-in: ${r.error}`);
160
+ const tok = await post('/oauth/token', new URLSearchParams({
161
+ grant_type: 'authorization_code', code: r.code, code_verifier: verifier,
162
+ client_id: clientId, redirect_uri: cb.redirectUri, resource: ENDPOINT,
163
+ }).toString(), 'application/x-www-form-urlencoded');
164
+ const key = String(tok.access_token ?? '');
165
+ if (!key)
166
+ throw new Error('sekura.ai answered without a key');
167
+ return key;
168
+ }
169
+ finally {
170
+ cb.close();
171
+ }
172
+ }
package/dist/catalogue.js CHANGED
@@ -69,13 +69,17 @@ export function onPath(bin) {
69
69
  const SIGN_IN = 'the first time the tool uses Sekura, a sekura.ai page opens in your browser: sign in with GitHub and approve.';
70
70
  const url = { url: ENDPOINT };
71
71
  const http = { type: 'http', url: ENDPOINT };
72
+ /** The header a key is sent in, as JSON configurations spell it. */
73
+ const auth = (key) => (key ? { headers: { Authorization: `Bearer ${key}` } } : {});
74
+ /** True when a target sends a key, so its tool needs no sign-in. */
75
+ export const carriesKey = (t) => JSON.stringify(t).includes('Bearer sek_');
72
76
  export const TOOLS = [
73
77
  {
74
78
  id: 'tools-claude', name: 'Claude Code',
75
79
  detect: (h) => existsSync(join(h, '.claude')) || existsSync(join(h, '.claude.json')) || onPath('claude'),
76
- user: (h) => [
77
- { kind: 'command', bin: 'claude', add: ['mcp', 'add', '--transport', 'http', '-s', 'user', SERVER_NAME, ENDPOINT], remove: ['mcp', 'remove', '-s', 'user', SERVER_NAME], get: ['mcp', 'get', SERVER_NAME] },
78
- { kind: 'json', path: join(h, '.claude.json'), key: 'mcpServers', entry: http },
80
+ user: (h, key) => [
81
+ { kind: 'command', bin: 'claude', add: ['mcp', 'add', '--transport', 'http', '-s', 'user', SERVER_NAME, ENDPOINT, ...(key ? ['--header', `Authorization: Bearer ${key}`] : [])], remove: ['mcp', 'remove', '-s', 'user', SERVER_NAME], get: ['mcp', 'get', SERVER_NAME] },
82
+ { kind: 'json', path: join(h, '.claude.json'), key: 'mcpServers', entry: { ...http, ...auth(key) } },
79
83
  ],
80
84
  project: (cwd) => [{ kind: 'json', path: join(cwd, '.mcp.json'), key: 'mcpServers', entry: http }],
81
85
  signIn: `type /mcp in Claude Code, choose ${SERVER_NAME}, and sign in with GitHub.`,
@@ -83,14 +87,14 @@ export const TOOLS = [
83
87
  {
84
88
  id: 'tools-cursor', name: 'Cursor',
85
89
  detect: (h) => existsSync(join(h, '.cursor')) || onPath('cursor'),
86
- user: (h) => [{ kind: 'json', path: join(h, '.cursor', 'mcp.json'), key: 'mcpServers', entry: url }],
90
+ user: (h, key) => [{ kind: 'json', path: join(h, '.cursor', 'mcp.json'), key: 'mcpServers', entry: { ...url, ...auth(key) } }],
87
91
  project: (cwd) => [{ kind: 'json', path: join(cwd, '.cursor', 'mcp.json'), key: 'mcpServers', entry: url }],
88
92
  signIn: SIGN_IN,
89
93
  },
90
94
  {
91
95
  id: 'tools-copilot', name: 'GitHub Copilot in VS Code',
92
96
  detect: (h) => existsSync(codeUserDir(h)),
93
- user: (h) => [{ kind: 'json', path: join(codeUserDir(h), 'mcp.json'), key: 'servers', entry: http }],
97
+ user: (h, key) => [{ kind: 'json', path: join(codeUserDir(h), 'mcp.json'), key: 'servers', entry: { ...http, ...auth(key) } }],
94
98
  project: (cwd) => [{ kind: 'json', path: join(cwd, '.mcp.json'), key: 'mcpServers', entry: http }],
95
99
  signIn: SIGN_IN,
96
100
  },
@@ -102,11 +106,12 @@ export const TOOLS = [
102
106
  { kind: 'toml', path: join(h, '.codex', 'config.toml'), table: `mcp_servers.${SERVER_NAME}`, lines: [`url = "${ENDPOINT}"`] },
103
107
  ],
104
108
  signIn: `run codex mcp login ${SERVER_NAME}, or sign in when Codex first uses Sekura.`,
109
+ login: ['mcp', 'login', SERVER_NAME],
105
110
  },
106
111
  {
107
112
  id: 'tools-kiro', name: 'Kiro',
108
113
  detect: (h) => existsSync(join(h, '.kiro')) || onPath('kiro'),
109
- user: (h) => [{ kind: 'json', path: join(h, '.kiro', 'settings', 'mcp.json'), key: 'mcpServers', entry: url }],
114
+ user: (h, key) => [{ kind: 'json', path: join(h, '.kiro', 'settings', 'mcp.json'), key: 'mcpServers', entry: { ...url, ...auth(key) } }],
110
115
  project: (cwd) => [{ kind: 'json', path: join(cwd, '.kiro', 'settings', 'mcp.json'), key: 'mcpServers', entry: url }],
111
116
  signIn: SIGN_IN,
112
117
  },
@@ -115,26 +120,26 @@ export const TOOLS = [
115
120
  // A fresh v4 install has no global-storage folder until the extension
116
121
  // writes one, so the installed extension and ~/.cline count too.
117
122
  detect: (h) => existsSync(join(h, '.cline')) || hasExtension(h, 'saoudrizwan.claude-dev') || existsSync(join(codeUserDir(h), 'globalStorage', 'saoudrizwan.claude-dev')),
118
- user: (h) => [{ kind: 'json', path: clineSettingsPath(h), key: 'mcpServers', entry: { type: 'streamableHttp', url: ENDPOINT } }],
123
+ user: (h, key) => [{ kind: 'json', path: clineSettingsPath(h), key: 'mcpServers', entry: { type: 'streamableHttp', url: ENDPOINT, ...auth(key) } }],
119
124
  signIn: SIGN_IN,
120
125
  },
121
126
  {
122
127
  id: 'tools-zed', name: 'Zed',
123
128
  detect: (h) => existsSync(join(xdg(h), 'zed')) || onPath('zed'),
124
- user: (h) => [{ kind: 'json', path: join(xdg(h), 'zed', 'settings.json'), key: 'context_servers', entry: url }],
129
+ user: (h, key) => [{ kind: 'json', path: join(xdg(h), 'zed', 'settings.json'), key: 'context_servers', entry: { ...url, ...auth(key) } }],
125
130
  signIn: `${SIGN_IN} Zed keeps the sign-in in your system keychain.`,
126
131
  },
127
132
  {
128
133
  id: 'tools-antigravity', name: 'Antigravity',
129
134
  detect: (h) => existsSync(join(h, '.gemini', 'config')),
130
- user: (h) => [{ kind: 'json', path: join(h, '.gemini', 'config', 'mcp_config.json'), key: 'mcpServers', entry: { serverUrl: ENDPOINT } }],
135
+ user: (h, key) => [{ kind: 'json', path: join(h, '.gemini', 'config', 'mcp_config.json'), key: 'mcpServers', entry: { serverUrl: ENDPOINT, ...auth(key) } }],
131
136
  project: (cwd) => [{ kind: 'json', path: join(cwd, '.agents', 'mcp_config.json'), key: 'mcpServers', entry: { serverUrl: ENDPOINT } }],
132
137
  signIn: SIGN_IN,
133
138
  },
134
139
  {
135
140
  id: 'tools-opencode', name: 'OpenCode',
136
141
  detect: (h) => existsSync(join(xdg(h), 'opencode')) || onPath('opencode'),
137
- user: (h) => [{ kind: 'json', path: join(xdg(h), 'opencode', 'opencode.json'), key: 'mcp', entry: { type: 'remote', url: ENDPOINT } }],
142
+ user: (h, key) => [{ kind: 'json', path: join(xdg(h), 'opencode', 'opencode.json'), key: 'mcp', entry: { type: 'remote', url: ENDPOINT, ...auth(key) } }],
138
143
  project: (cwd) => [{ kind: 'json', path: join(cwd, 'opencode.json'), key: 'mcp', entry: { type: 'remote', url: ENDPOINT } }],
139
144
  signIn: `OpenCode registers itself with Sekura and opens the sign-in on first use; ${SIGN_IN}`,
140
145
  },
@@ -165,15 +170,16 @@ export const TOOLS = [
165
170
  detect: () => onPath('devin'),
166
171
  user: () => [{ kind: 'command', bin: 'devin', add: ['mcp', 'add', SERVER_NAME, ENDPOINT], remove: ['mcp', 'remove', SERVER_NAME] }],
167
172
  signIn: `run devin mcp login ${SERVER_NAME}.`,
173
+ login: ['mcp', 'login', SERVER_NAME],
168
174
  },
169
175
  {
170
176
  id: 'tools-goose', name: 'Goose',
171
177
  detect: (h) => existsSync(gooseDir(h)) || onPath('goose'),
172
178
  // The streamable_http shape of Goose's ExtensionConfig (block/goose,
173
179
  // crates/goose/src/agents/extension.rs; documentation/docs/guides/config-files.md).
174
- user: (h) => [{ kind: 'yaml', path: join(gooseDir(h), 'config.yaml'), key: 'extensions', lines: [
180
+ user: (h, key) => [{ kind: 'yaml', path: join(gooseDir(h), 'config.yaml'), key: 'extensions', lines: [
175
181
  'type: streamable_http', `name: ${SERVER_NAME}`, 'enabled: true', `uri: ${ENDPOINT}`,
176
- 'envs: {}', 'env_keys: []', 'headers: {}', 'timeout: 300', 'bundled: null', 'available_tools: []',
182
+ 'envs: {}', 'env_keys: []', key ? `headers: { Authorization: "Bearer ${key}" }` : 'headers: {}', 'timeout: 300', 'bundled: null', 'available_tools: []',
177
183
  ] }],
178
184
  signIn: SIGN_IN,
179
185
  },
package/dist/cli.js CHANGED
@@ -4,15 +4,17 @@
4
4
  // npx sekura@latest init
5
5
  //
6
6
  // Sekura's MCP server is remote (https://sekura.ai/mcp), so this package
7
- // runs no server and holds no credential: it writes the server's address
8
- // into the tools on this machine, installs the skill that teaches an agent
9
- // the quote-then-confirm rule, and gets out of the way. Signing in happens
10
- // in the tool, on a sekura.ai page, the first time it uses Sekura.
7
+ // runs no server: it writes the server's address into the tools on this
8
+ // machine, installs the skill that teaches an agent the quote-then-confirm
9
+ // rule, and then finishes getting started in the terminal. It signs in once
10
+ // on a sekura.ai page and gives the resulting key to each tool, so none of
11
+ // them asks, then helps with the GitHub App, a repository and the free scan.
11
12
  import { Command } from 'commander';
12
13
  import { createRequire } from 'node:module';
13
14
  import { initCommand } from './commands/init.js';
14
15
  import { statusCommand } from './commands/status.js';
15
16
  import { uninstallCommand } from './commands/uninstall.js';
17
+ import { loginCommand, logoutCommand } from './commands/login.js';
16
18
  import { DOCS } from './catalogue.js';
17
19
  const { version } = createRequire(import.meta.url)('../package.json');
18
20
  const program = new Command();
@@ -21,8 +23,10 @@ program
21
23
  .description(`Connect your coding tool to Sekura.\n\n npx sekura@latest init\n\nDocumentation: ${DOCS}`)
22
24
  .version(version)
23
25
  .addCommand(initCommand, { isDefault: false })
26
+ .addCommand(loginCommand)
24
27
  .addCommand(statusCommand)
25
- .addCommand(uninstallCommand);
28
+ .addCommand(uninstallCommand)
29
+ .addCommand(logoutCommand);
26
30
  try {
27
31
  await program.parseAsync(process.argv);
28
32
  }
@@ -1,13 +1,16 @@
1
- // `sekura init`: detect, choose, review, apply, verify. Under a minute, and
2
- // nothing is written before the review is shown and (unless --yes) agreed.
1
+ // `sekura init`: detect, choose, sign in, review, apply, verify, then the
2
+ // rest of getting started (the GitHub App, a repository, the free scan) so
3
+ // none of it is left for the coding tool. Nothing is written before the
4
+ // review is shown and (unless --yes) agreed.
3
5
  import { Command } from 'commander';
4
6
  import { checkbox, confirm, select } from '@inquirer/prompts';
5
7
  import { realpathSync } from 'node:fs';
6
8
  import { homedir } from 'node:os';
7
9
  import { dirname, resolve } from 'node:path';
8
- import { DOCS, ENDPOINT, TOOLS, home } from '../catalogue.js';
10
+ import { DOCS, ENDPOINT, TOOLS, carriesKey, home } from '../catalogue.js';
9
11
  import { applyStep, buildPlan, describeStep, detectTools } from '../plan.js';
10
12
  import { verifyEndpoint } from '../verify.js';
13
+ import { checkAccount, howToScan, obtainKey, offerFreeScan, pickRepository, runToolLogins } from '../guide.js';
11
14
  import { banner, cross, dot, say, tick } from '../ui.js';
12
15
  /** The home folder (or the top of the disk) is no project: project scope
13
16
  * there would put AGENTS.md and .mcp.json in it for every tool to find. */
@@ -69,8 +72,16 @@ export async function runInit(opts) {
69
72
  if (opts.skill === undefined && interactive) {
70
73
  skill = await confirm({ message: 'Also install the sekura skill, which teaches the agent the quote-then-confirm rule?', default: true });
71
74
  }
72
- // 3. The plan, shown before anything happens.
73
- const plan = buildPlan({ tools: chosen, scope, skill, cwd });
75
+ // 3. Sign in once, here, so the key goes into each tool and none of them
76
+ // asks. Project files never carry it (they are committed), so project
77
+ // scope skips this. A dry run uses a key it already has, never a browser.
78
+ let session = null;
79
+ if (opts.login !== false && scope === 'user') {
80
+ say();
81
+ session = await obtainKey({ interactive: interactive && !opts.dryRun });
82
+ }
83
+ // 4. The plan, shown before anything happens.
84
+ const plan = buildPlan({ tools: chosen, scope, skill, cwd, key: session?.key });
74
85
  say();
75
86
  say(opts.dryRun ? ' Dry run. This would:' : ' This will:');
76
87
  for (const s of plan.steps)
@@ -85,7 +96,7 @@ export async function runInit(opts) {
85
96
  say(' Nothing was changed.');
86
97
  return { written: 0, byHand: 0, failed: 0 };
87
98
  }
88
- // 4. Apply, one step at a time, saying what each did.
99
+ // 5. Apply, one step at a time, saying what each did.
89
100
  let written = 0, byHand = 0, failed = 0;
90
101
  const signIns = new Map();
91
102
  for (const s of plan.steps) {
@@ -113,20 +124,41 @@ export async function runInit(opts) {
113
124
  break;
114
125
  }
115
126
  if (s.kind === 'tool' && (r.status === 'written' || r.status === 'ran' || r.status === 'unchanged'))
116
- signIns.set(s.tool.name, s.tool.signIn);
127
+ signIns.set(s.tool.name, carriesKey(s.target) ? null : s.tool.signIn);
117
128
  }
118
- // 5. Verify the address, and say what comes next.
129
+ // Tools whose entry cannot carry the key sign in with their own command,
130
+ // now, while the browser is signed in to sekura.ai.
131
+ if (session)
132
+ for (const name of await runToolLogins(plan.steps.filter((s) => s.kind === 'tool' && signIns.has(s.tool.name)), interactive))
133
+ signIns.set(name, null);
134
+ // 6. Verify the address.
119
135
  if (opts.verify !== false) {
120
136
  const v = await verifyEndpoint(ENDPOINT);
121
137
  say(`${v.ok ? tick : cross} ${v.detail}`);
122
138
  }
139
+ // 7. The account, a repository and the free scan, with the key.
140
+ let repo = null;
141
+ if (session && signIns.size) {
142
+ say();
143
+ await checkAccount(session, interactive);
144
+ repo = await pickRepository(session, cwd, interactive);
145
+ await offerFreeScan(session, repo, interactive);
146
+ }
147
+ // 8. What is left, per tool.
123
148
  say();
124
149
  if (signIns.size) {
125
- say(' Next, sign in once per tool:');
126
- for (const [name, how] of signIns)
127
- say(`${dot} ${name}: ${how}`);
150
+ const ready = [...signIns].filter(([, how]) => how === null).map(([name]) => name);
151
+ const pending = [...signIns].filter(([, how]) => how !== null);
152
+ if (ready.length)
153
+ say(`${tick} Ready, no sign-in needed: ${ready.join(', ')}`);
154
+ if (pending.length) {
155
+ say(' Sign in once per tool:');
156
+ for (const [name, how] of pending)
157
+ say(`${dot} ${name}: ${how}`);
158
+ }
159
+ say();
160
+ howToScan([...signIns.keys()], repo);
128
161
  say();
129
- say(' Then ask it which of your repositories Sekura can scan, and for a quote to scan one.');
130
162
  }
131
163
  say(` Documentation and every other tool: ${DOCS}`);
132
164
  say();
@@ -134,13 +166,14 @@ export async function runInit(opts) {
134
166
  }
135
167
  export const initCommand = new Command('init')
136
168
  .description('Connect the coding tools on this machine to Sekura, and install the sekura skill.')
137
- .option('-y, --yes', 'apply without asking; every detected tool, user scope, with the skill')
169
+ .option('-y, --yes', 'apply without asking; every detected tool, user scope, with the skill, and the key in SEKURA_API_KEY or saved by sekura login')
138
170
  .option('--no-skill', 'the MCP server only: no skill, no guidance block')
139
171
  .option('--scope <scope>', 'user (every project) or project (this directory)', (v) => { if (v !== 'user' && v !== 'project')
140
172
  throw new Error('--scope is user or project'); return v; })
141
173
  .option('--tools <ids>', `only these, comma-separated: ${TOOLS.map((t) => t.id.replace('tools-', '')).join(', ')}`)
142
174
  .option('--dry-run', 'show the plan and change nothing')
143
175
  .option('--no-verify', 'do not check that the Sekura server answers')
176
+ .option('--no-login', 'do not sign in here; each tool signs in on first use')
144
177
  .action(async (opts) => {
145
178
  const r = await runInit(opts);
146
179
  process.exitCode = r.failed ? 1 : 0;
@@ -0,0 +1,73 @@
1
+ // `sekura login`: sign in here and put the key into every tool already
2
+ // connected, then the account, a repository and the free scan. For someone
3
+ // who skipped the sign-in during init, revoked the key, or wants another
4
+ // repository. `sekura logout`: forget the saved key.
5
+ import { Command } from 'commander';
6
+ import { applyStep, buildPlan, detectTools, stepPresent } from '../plan.js';
7
+ import { carriesKey } from '../catalogue.js';
8
+ import { credentialsPath, forgetKey } from '../auth.js';
9
+ import { checkAccount, howToScan, obtainKey, offerFreeScan, pickRepository, runToolLogins } from '../guide.js';
10
+ import { cross, dot, say, tick } from '../ui.js';
11
+ export async function runLogin(opts) {
12
+ const cwd = opts.cwd ?? process.cwd();
13
+ const interactive = !!(process.stdin.isTTY && process.stderr.isTTY);
14
+ say();
15
+ const session = await obtainKey({ interactive, ask: false });
16
+ if (!session) {
17
+ say(' Not signed in; nothing was changed.');
18
+ say();
19
+ return 1;
20
+ }
21
+ // The tools Sekura is already in (user scope), with or without a key,
22
+ // get the entry again with this key.
23
+ const ids = detectTools(cwd).map((t) => t.id);
24
+ const plain = buildPlan({ tools: ids, scope: 'user', skill: false, cwd });
25
+ const connected = plain.steps.flatMap((s) => (s.kind === 'tool' && stepPresent(s) !== false ? [s.tool.id] : []));
26
+ const keyed = buildPlan({ tools: connected, scope: 'user', skill: false, cwd, key: session.key });
27
+ let failed = 0;
28
+ const names = [];
29
+ if (keyed.steps.length)
30
+ say();
31
+ for (const s of keyed.steps) {
32
+ if (s.kind !== 'tool')
33
+ continue;
34
+ names.push(s.tool.name);
35
+ if (!carriesKey(s.target))
36
+ continue;
37
+ const r = applyStep(s);
38
+ if (r.status === 'written' || r.status === 'ran')
39
+ say(`${tick} ${s.tool.name}: ${r.where}`);
40
+ else if (r.status === 'unchanged')
41
+ say(`${tick} ${s.tool.name}: already has this key`);
42
+ else if (r.status === 'by-hand')
43
+ say(`${dot} ${s.tool.name}: ${r.where}: ${r.why}; add the header by hand with the key in ${credentialsPath()}`);
44
+ else if (r.status === 'failed') {
45
+ failed++;
46
+ say(`${cross} ${s.tool.name}: ${r.where}: ${r.why}`);
47
+ }
48
+ }
49
+ await runToolLogins(keyed.steps, interactive);
50
+ if (!names.length)
51
+ say(`${dot} No tool on this machine has Sekura yet: run sekura init.`);
52
+ say();
53
+ await checkAccount(session, interactive);
54
+ const repo = await pickRepository(session, cwd, interactive);
55
+ await offerFreeScan(session, repo, interactive);
56
+ if (names.length) {
57
+ say();
58
+ howToScan(names, repo);
59
+ }
60
+ say();
61
+ return failed;
62
+ }
63
+ export const loginCommand = new Command('login')
64
+ .description('Sign in to Sekura here, give the key to every connected tool, and pick a repository.')
65
+ .action(async () => { process.exitCode = (await runLogin({})) ? 1 : 0; });
66
+ export const logoutCommand = new Command('logout')
67
+ .description('Forget the Sekura key saved on this machine.')
68
+ .action(() => {
69
+ say();
70
+ say(forgetKey() ? `${tick} Removed ${credentialsPath()}` : `${dot} No saved key at ${credentialsPath()}`);
71
+ say(' Tools that were given the key keep it until you run sekura uninstall. To end it everywhere: https://sekura.ai/app/settings → Agents and API keys.');
72
+ say();
73
+ });
@@ -1,8 +1,10 @@
1
1
  // `sekura status`: which tools are here, which carry Sekura, and whether the
2
2
  // server answers. Reads only.
3
3
  import { Command } from 'commander';
4
- import { ENDPOINT } from '../catalogue.js';
4
+ import { ENDPOINT, carriesKey } from '../catalogue.js';
5
5
  import { buildPlan, detectTools, stepPresent } from '../plan.js';
6
+ import { readKey, redact } from '../auth.js';
7
+ import { getAccount, SignedOut } from '../api.js';
6
8
  import { verifyEndpoint } from '../verify.js';
7
9
  import { cross, dot, say, tick } from '../ui.js';
8
10
  export async function runStatus(opts) {
@@ -12,20 +14,39 @@ export async function runStatus(opts) {
12
14
  say();
13
15
  if (!detected.length)
14
16
  say(' No supported coding tool found on this machine.');
15
- const plan = buildPlan({ tools: detected.map((t) => t.id), scope, skill: true, cwd });
16
- for (const s of plan.steps) {
17
- const p = stepPresent(s);
17
+ const key = readKey();
18
+ const plan = buildPlan({ tools: detected.map((t) => t.id), scope, skill: true, cwd, key });
19
+ const plain = key ? buildPlan({ tools: detected.map((t) => t.id), scope, skill: true, cwd }) : plan;
20
+ plan.steps.forEach((s, i) => {
21
+ let p = stepPresent(s);
22
+ // An entry without the key is still connected; its tool signs in itself.
23
+ let how = s.kind === 'tool' && p === true && carriesKey(s.target) ? ' (with your key)' : '';
24
+ if (s.kind === 'tool' && p !== true && plain !== plan && stepPresent(plain.steps[i]) === true) {
25
+ p = true;
26
+ how = ' (signs in in the tool)';
27
+ }
18
28
  const mark = p === true ? tick : p === 'unknown' ? dot : cross;
19
29
  const noun = s.kind === 'tool' ? 'connected' : 'installed';
20
- const word = p === true ? noun : p === 'unknown' ? (s.kind === 'tool' && s.target.kind === 'command' ? 'kept by the tool itself; ask it' : 'its file has comments; look by hand') : `not ${noun}`;
30
+ const word = p === true ? noun + how : p === 'unknown' ? (s.kind === 'tool' && s.target.kind === 'command' ? 'kept by the tool itself; ask it' : 'its file has comments; look by hand') : `not ${noun}`;
21
31
  const label = s.kind === 'tool' ? s.tool.name : s.kind === 'skill' ? `skill ${s.path}` : `guidance ${s.path}`;
22
32
  say(`${mark} ${label}: ${word}`);
23
- }
33
+ });
24
34
  for (const s of plan.skipped)
25
35
  say(`${dot} ${s.tool.name}: ${s.why}`);
26
36
  if (opts.verify !== false) {
27
37
  const v = await verifyEndpoint(ENDPOINT);
28
38
  say(`${v.ok ? tick : cross} ${v.detail}`);
39
+ if (key) {
40
+ try {
41
+ const a = await getAccount(key);
42
+ say(`${tick} Signed in to Sekura as ${a.email ?? a.name ?? redact(key)}`);
43
+ }
44
+ catch (e) {
45
+ say(`${cross} The saved key ${redact(key)} ${e instanceof SignedOut ? 'no longer works: run sekura login' : `could not be checked: ${e.message}`}`);
46
+ }
47
+ }
48
+ else
49
+ say(`${dot} Not signed in here: run sekura login to give your tools a key`);
29
50
  }
30
51
  say();
31
52
  }
@@ -6,6 +6,7 @@
6
6
  import { Command } from 'commander';
7
7
  import { confirm } from '@inquirer/prompts';
8
8
  import { buildPlan, describeStep, detectTools, removeStep } from '../plan.js';
9
+ import { credentialsPath, forgetKey } from '../auth.js';
9
10
  import { cross, dot, say, tick } from '../ui.js';
10
11
  export async function runUninstall(opts) {
11
12
  const cwd = opts.cwd ?? process.cwd();
@@ -16,6 +17,8 @@ export async function runUninstall(opts) {
16
17
  say(' This will remove Sekura from:');
17
18
  for (const s of plan.steps)
18
19
  say(`${dot} ${describeStep(s).split(':')[0]}`);
20
+ if (!opts.keepLogin)
21
+ say(`${dot} the saved key, ${credentialsPath()}`);
19
22
  say(' and change nothing else.');
20
23
  say();
21
24
  if (interactive && !(await confirm({ message: 'Remove?', default: false }))) {
@@ -38,6 +41,8 @@ export async function runUninstall(opts) {
38
41
  say(`${cross} ${r.where}: ${'why' in r ? r.why : r.status}`);
39
42
  }
40
43
  }
44
+ if (!opts.keepLogin && forgetKey())
45
+ say(`${tick} ${credentialsPath()}`);
41
46
  say();
42
47
  say(' To end the connection on Sekura\'s side too: https://sekura.ai/app/settings → Agents and API keys.');
43
48
  say();
@@ -47,6 +52,7 @@ export const uninstallCommand = new Command('uninstall')
47
52
  .description('Remove Sekura from every tool on this machine, and the skill.')
48
53
  .option('-y, --yes', 'remove without asking')
49
54
  .option('--keep-skill', 'leave the skill and the guidance block in place')
55
+ .option('--keep-login', 'leave the saved Sekura key in place')
50
56
  .option('--scope <scope>', 'user or project', 'user')
51
57
  .action(async (opts) => {
52
58
  process.exitCode = (await runUninstall(opts)) ? 1 : 0;
package/dist/guide.js ADDED
@@ -0,0 +1,215 @@
1
+ // The steps after the install that used to happen inside the coding tool:
2
+ // signing in, the GitHub App, a repository, and the free first scan. Shared
3
+ // by `init` and `login`. Nothing here starts a scan or charges anything: the
4
+ // free scan is started by the person on sekura.ai, and every other scan is
5
+ // quoted and agreed in the coding tool.
6
+ import { confirm, input, select } from '@inquirer/prompts';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { forgetKey, openBrowser, readKey, redact, saveKey, signIn } from './auth.js';
9
+ import { getAccount, listRepositories, SignedOut } from './api.js';
10
+ import { carriesKey, onPath, SERVER_NAME } from './catalogue.js';
11
+ import { currentGithubRepo } from './repo.js';
12
+ import { cross, dot, say, tick } from './ui.js';
13
+ const START = 'https://sekura.ai/app/start';
14
+ const DASHBOARD = 'https://sekura.ai/app';
15
+ /**
16
+ * A key that works: SEKURA_API_KEY, the stored one, or a fresh sign-in in
17
+ * the browser when the person agrees. Null when there is none, and the tools
18
+ * then sign in on their own, as they always could.
19
+ */
20
+ export async function obtainKey(opts) {
21
+ const fromEnv = !!process.env.SEKURA_API_KEY?.trim();
22
+ const existing = readKey();
23
+ if (existing) {
24
+ try {
25
+ const account = await getAccount(existing);
26
+ say(`${tick} Signed in to Sekura as ${account.email ?? account.name ?? redact(existing)}${fromEnv ? ' (SEKURA_API_KEY)' : ''}`);
27
+ return { key: existing, account };
28
+ }
29
+ catch (e) {
30
+ if (!(e instanceof SignedOut)) {
31
+ // Offline, or Sekura did not answer: the key is likely fine, and
32
+ // writing it now saves the person a sign-in later.
33
+ say(`${dot} Using your saved Sekura key ${redact(existing)}; Sekura could not be asked who it belongs to (${e.message}).`);
34
+ return { key: existing, account: null };
35
+ }
36
+ if (fromEnv) {
37
+ say(`${cross} SEKURA_API_KEY is not a valid Sekura key (${e.message}); it was not used.`);
38
+ return null;
39
+ }
40
+ forgetKey();
41
+ say(`${dot} Your saved Sekura key no longer works (${e.message}); sign in again.`);
42
+ }
43
+ }
44
+ if (!opts.interactive)
45
+ return null;
46
+ if (opts.ask !== false && !(await confirm({ message: 'Sign in to Sekura now, so your tools do not have to? (opens your browser)', default: true })))
47
+ return null;
48
+ try {
49
+ const key = await signIn({
50
+ onUrl: (url) => { say(); say(' Opening your browser to sign in to Sekura with GitHub. If it does not open, go to:'); say(` ${url}`); say(); },
51
+ });
52
+ let account = null;
53
+ try {
54
+ account = await getAccount(key);
55
+ }
56
+ catch { /* shown below as unknown */ }
57
+ const where = saveKey(key, account?.email);
58
+ say(`${tick} Signed in to Sekura as ${account?.email ?? account?.name ?? 'you'}; the key (${redact(key)}) is kept in ${where}`);
59
+ return { key, account };
60
+ }
61
+ catch (e) {
62
+ say(`${cross} ${e.message}`);
63
+ say(' Your tools will ask you to sign in the first time they use Sekura instead.');
64
+ return null;
65
+ }
66
+ }
67
+ /** What the account still needs before a scan can start, with the page for it. */
68
+ export async function checkAccount(s, interactive) {
69
+ const a = s.account;
70
+ if (!a)
71
+ return;
72
+ const todo = [];
73
+ if (a.termsAccepted === false)
74
+ todo.push('accept the Terms');
75
+ if (a.emailVerified === false)
76
+ todo.push('verify your email');
77
+ if (!todo.length)
78
+ return;
79
+ const page = a.links?.dashboard ?? DASHBOARD;
80
+ say(`${dot} Before a scan can start, ${todo.join(' and ')} on sekura.ai: ${page}`);
81
+ if (interactive && (await confirm({ message: 'Open sekura.ai to do that now?', default: true }))) {
82
+ openBrowser(page);
83
+ await input({ message: 'Press Enter when you are done' });
84
+ }
85
+ }
86
+ const same = (a, b) => a.toLowerCase() === b.toLowerCase();
87
+ /**
88
+ * The repository to scan first: the one in this folder when Sekura can scan
89
+ * it, else the person's choice. Offers the GitHub App install when there is
90
+ * nothing to scan, or when this folder's repository is not covered.
91
+ */
92
+ export async function pickRepository(s, cwd, interactive) {
93
+ const here = currentGithubRepo(cwd);
94
+ const start = s.account?.links?.start ?? START;
95
+ let repos = [];
96
+ for (let attempt = 0;; attempt++) {
97
+ let list;
98
+ try {
99
+ list = await listRepositories(s.key);
100
+ }
101
+ catch (e) {
102
+ say(`${cross} Could not list your repositories: ${e.message}`);
103
+ return null;
104
+ }
105
+ for (const i of list.installations) {
106
+ if (i.error === 'github_sign_in_expired')
107
+ say(`${dot} ${i.account}: GitHub access has expired; sign in at ${DASHBOARD} again to refresh it.`);
108
+ else if (i.error === 'installation_gone')
109
+ say(`${dot} ${i.account}: the Sekura GitHub App is no longer installed there.`);
110
+ }
111
+ repos = list.installations.flatMap((i) => i.repositories);
112
+ const covered = !!here && repos.some((r) => same(r.fullName, here));
113
+ if (repos.length && (!here || covered))
114
+ break;
115
+ say(repos.length
116
+ ? `${dot} ${here} (this folder) is not one Sekura can scan yet: the Sekura GitHub App needs access to it.`
117
+ : `${dot} The Sekura GitHub App is not installed on any of your GitHub accounts yet, so there is nothing to scan.`);
118
+ if (!interactive) {
119
+ say(` Install it and choose repositories at ${start}`);
120
+ break;
121
+ }
122
+ if (attempt >= 3)
123
+ break;
124
+ const go = await select({
125
+ message: repos.length ? `Give Sekura access to ${here}?` : 'Install the Sekura GitHub App?',
126
+ choices: [
127
+ { name: 'Yes, open sekura.ai (the GitHub App asks which repositories)', value: true },
128
+ { name: repos.length ? 'No, pick from the repositories Sekura can already scan' : 'Not now', value: false },
129
+ ],
130
+ });
131
+ if (!go)
132
+ break;
133
+ openBrowser(start);
134
+ say(` Opened ${start}`);
135
+ await input({ message: 'Press Enter once the GitHub App is installed' });
136
+ }
137
+ const scannable = repos.filter((r) => !r.empty);
138
+ if (!scannable.length)
139
+ return null;
140
+ const hereRepo = here ? scannable.find((r) => same(r.fullName, here)) : undefined;
141
+ if (!interactive)
142
+ return hereRepo?.fullName ?? null;
143
+ if (scannable.length === 1) {
144
+ say(`${tick} Repository: ${scannable[0].fullName}`);
145
+ return scannable[0].fullName;
146
+ }
147
+ const ordered = hereRepo ? [hereRepo, ...repos.filter((r) => r !== hereRepo)] : repos;
148
+ const picked = await select({
149
+ message: 'Which repository should Sekura scan first?',
150
+ pageSize: 12,
151
+ choices: [
152
+ ...ordered.map((r) => ({
153
+ name: `${r.fullName}${r === hereRepo ? ' (this folder)' : ''}${r.private ? ' (private)' : ''}`,
154
+ value: r.fullName,
155
+ disabled: r.empty ? '(empty, nothing to scan)' : false,
156
+ })),
157
+ { name: 'Skip for now', value: '' },
158
+ ],
159
+ });
160
+ return picked || null;
161
+ }
162
+ /** The free first scan, which only sekura.ai starts, when it is still there. */
163
+ export async function offerFreeScan(s, repo, interactive) {
164
+ if (!s.account?.freeScanAvailable)
165
+ return false;
166
+ const start = s.account.links?.start ?? START;
167
+ say();
168
+ say(` Your free first scan is still available. It is started on sekura.ai${repo ? `: choose ${repo} there` : ''}.`);
169
+ if (interactive && (await confirm({ message: `Open sekura.ai to start your free scan${repo ? ` of ${repo}` : ''}?`, default: true }))) {
170
+ openBrowser(start);
171
+ say(`${tick} Opened ${start}`);
172
+ return true;
173
+ }
174
+ say(` Start it any time at ${start}`);
175
+ return false;
176
+ }
177
+ /**
178
+ * Runs the tool's own sign-in for each written entry that could not carry
179
+ * the key (Codex, Devin). The browser is already signed in to sekura.ai, so
180
+ * each is one Approve. Returns the names of the tools signed in.
181
+ */
182
+ export async function runToolLogins(steps, interactive) {
183
+ const done = new Set();
184
+ if (!interactive)
185
+ return done;
186
+ for (const s of steps) {
187
+ if (s.kind !== 'tool' || !s.tool.login || carriesKey(s.target))
188
+ continue;
189
+ const bin = s.target.kind === 'command' ? s.target.bin : null;
190
+ if (!bin || !onPath(bin))
191
+ continue;
192
+ const cmd = `${bin} ${s.tool.login.join(' ')}`;
193
+ if (!(await confirm({ message: `Sign ${s.tool.name} in to Sekura now? (runs ${cmd}; approve in the browser)`, default: true })))
194
+ continue;
195
+ const r = spawnSync(bin, s.tool.login, { stdio: 'inherit' });
196
+ if (r.status === 0) {
197
+ done.add(s.tool.name);
198
+ say(`${tick} ${s.tool.name} signed in`);
199
+ }
200
+ else
201
+ say(`${cross} ${cmd} did not finish; run it yourself later.`);
202
+ }
203
+ return done;
204
+ }
205
+ /** How to ask for a scan from each connected tool. */
206
+ export function howToScan(tools, repo) {
207
+ const target = repo ?? 'owner/repo';
208
+ say(' To scan from your coding tool (every scan is quoted first and starts only after you agree):');
209
+ for (const name of tools) {
210
+ if (name === 'Claude Code')
211
+ say(`${dot} ${name}: /${SERVER_NAME} scan ${target} (/${SERVER_NAME} help lists every command)`);
212
+ else
213
+ say(`${dot} ${name}: ask "scan ${target} with Sekura"`);
214
+ }
215
+ }
package/dist/plan.js CHANGED
@@ -10,6 +10,8 @@ export function detectTools(cwd = process.cwd()) {
10
10
  const h = home();
11
11
  return TOOLS.filter((t) => t.detect(h, cwd));
12
12
  }
13
+ /** `key`, when given, is written into each user-scope entry that can carry
14
+ * one (catalogue.ts); project scope never carries it. */
13
15
  export function buildPlan(opts) {
14
16
  const cwd = opts.cwd ?? process.cwd();
15
17
  const root = opts.scope === 'user' ? home() : cwd;
@@ -19,7 +21,7 @@ export function buildPlan(opts) {
19
21
  const tool = toolById(id);
20
22
  if (!tool)
21
23
  throw new Error(`no tool named ${id}; one of ${TOOLS.map((t) => t.id).join(', ')}`);
22
- const targets = opts.scope === 'user' ? tool.user(home()) : tool.project?.(cwd);
24
+ const targets = opts.scope === 'user' ? tool.user(home(), opts.key ?? undefined) : tool.project?.(cwd);
23
25
  if (!targets) {
24
26
  skipped.push({ tool, why: 'has no project-level configuration; run with --scope user' });
25
27
  continue;
package/dist/repo.js ADDED
@@ -0,0 +1,14 @@
1
+ // The GitHub repository the person is standing in, if any: offered first
2
+ // when they pick one to scan.
3
+ import { spawnSync } from 'node:child_process';
4
+ /** `owner/repo` from a GitHub remote URL, or null for any other host. */
5
+ export function parseGithubRemote(url) {
6
+ const m = /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https?:\/\/(?:[^@/]+@)?github\.com\/)([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(url.trim());
7
+ return m ? `${m[1]}/${m[2]}` : null;
8
+ }
9
+ export function currentGithubRepo(cwd) {
10
+ const r = spawnSync('git', ['config', '--get', 'remote.origin.url'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
11
+ if (r.error || r.status !== 0)
12
+ return null;
13
+ return parseGithubRemote(r.stdout);
14
+ }
package/dist/ui.js CHANGED
@@ -9,5 +9,6 @@ export function banner() {
9
9
  say();
10
10
  say(' Sekura — connect your coding tool');
11
11
  say(' Every scan is quoted first and started only after you agree.');
12
+ say(' Sign in once here, and your tools will not ask again.');
12
13
  say();
13
14
  }
package/dist/writers.js CHANGED
@@ -15,9 +15,18 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
15
  import { dirname } from 'node:path';
16
16
  import { spawnSync } from 'node:child_process';
17
17
  import { ENDPOINT, SERVER_NAME } from './catalogue.js';
18
- const describeJson = (t) => JSON.stringify({ [t.key]: { [SERVER_NAME]: t.entry } }, null, 2);
18
+ import { redactAll } from './auth.js';
19
+ // What is shown never carries a key: the review, the lines to add by hand
20
+ // and a failed command all print it as sek_live_…abcd. Lines to add by hand
21
+ // say where the whole key is kept instead.
22
+ const describeJson = (t) => redactAll(JSON.stringify({ [t.key]: { [SERVER_NAME]: t.entry } }, null, 2));
23
+ /** The key a target sends, if any. */
24
+ const keyOf = (t) => /Bearer (sek_[a-z]+_[A-Za-z0-9]+)/.exec(JSON.stringify(t))?.[1] ?? null;
19
25
  /** What applying the target would do, for the review before any write. */
20
26
  export function describe(t, dryRun = false) {
27
+ return redactAll(describeRaw(t, dryRun));
28
+ }
29
+ function describeRaw(t, dryRun) {
21
30
  const verb = dryRun ? 'would ' : '';
22
31
  switch (t.kind) {
23
32
  case 'json': return `${verb}write ${t.path}: ${t.key}.${SERVER_NAME} = ${JSON.stringify(t.entry)}`;
@@ -169,18 +178,21 @@ function yamlFields(lines) {
169
178
  function yamlOk(t, entry) {
170
179
  const f = yamlFields(entry);
171
180
  const want = yamlFields(t.lines);
172
- return f.type === want.type && f.uri === ENDPOINT && f.enabled === 'true';
181
+ // Goose may rewrite the header mapping in its own layout, so a key is
182
+ // looked for in the entry's text, not in one field's spelling.
183
+ const key = keyOf(t);
184
+ return f.type === want.type && f.uri === ENDPOINT && f.enabled === 'true' && (!key || entry.join('\n').includes(key));
173
185
  }
174
186
  const splitLines = (text) => text.replace(/\n$/, '').split('\n');
175
187
  export function applyYaml(t) {
176
188
  const text = existsSync(t.path) ? readFileSync(t.path, 'utf8') : '';
177
189
  if (/\t/.test(text.split('\n').filter((l) => /^\s/.test(l)).join(''))) {
178
- return { status: 'by-hand', where: t.path, why: 'it is indented with tabs, which this installer will not rewrite', snippet: `${t.key}:\n${yamlBlock(t, ' ')}` };
190
+ return { status: 'by-hand', where: t.path, why: 'it is indented with tabs, which this installer will not rewrite', snippet: redactAll(`${t.key}:\n${yamlBlock(t, ' ')}`) };
179
191
  }
180
192
  const lines = text ? splitLines(text) : [];
181
193
  const at = yamlLayout(lines, t.key);
182
194
  if ('inline' in at) {
183
- return { status: 'by-hand', where: t.path, why: `its ${t.key} is written on one line, which this installer will not rewrite`, snippet: `${t.key}:\n${yamlBlock(t, ' ')}` };
195
+ return { status: 'by-hand', where: t.path, why: `its ${t.key} is written on one line, which this installer will not rewrite`, snippet: redactAll(`${t.key}:\n${yamlBlock(t, ' ')}`) };
184
196
  }
185
197
  let out;
186
198
  if ('missing' in at) {
@@ -229,21 +241,24 @@ function spawn(bin, args) {
229
241
  }
230
242
  export function runCommand(bin, args) {
231
243
  const r = spawn(bin, args);
232
- const where = `${bin} ${args.join(' ')}`;
244
+ const where = redactAll(`${bin} ${args.join(' ')}`);
233
245
  if (r.error)
234
246
  return { status: 'failed', where, why: r.error.message };
235
247
  if (r.status !== 0)
236
- return { status: 'failed', where, why: (r.stderr || r.stdout || `exit ${r.status}`).trim().split('\n').slice(-3).join(' ') };
248
+ return { status: 'failed', where, why: redactAll((r.stderr || r.stdout || `exit ${r.status}`).trim().split('\n').slice(-3).join(' ')) };
237
249
  return { status: 'ran', where };
238
250
  }
239
- /** Whether the tool's own command lists our entry at our address. */
251
+ /** Whether the tool's own command lists our entry at our address, and with
252
+ * our key when the entry carries one. A tool that does not print its
253
+ * headers reads as not having it, and the entry is written again. */
240
254
  function commandHas(t) {
241
255
  if (!t.get)
242
256
  return 'unknown';
243
257
  const r = spawn(t.bin, t.get);
244
258
  if (r.error)
245
259
  return 'unknown';
246
- return r.status === 0 && `${r.stdout}`.includes(ENDPOINT);
260
+ const key = keyOf(t);
261
+ return r.status === 0 && `${r.stdout}`.includes(ENDPOINT) && (!key || `${r.stdout}`.includes(key));
247
262
  }
248
263
  /** Adds the entry. A tool that refuses because the name is taken is asked
249
264
  * what it holds: our address is "already there"; another one is replaced,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sekura",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Connect your coding tool to Sekura in one command: npx sekura@latest init",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "scripts": {
39
39
  "build": "tsc -p tsconfig.json && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"",
40
- "test": "npm run build && node --test test/init.test.mjs",
40
+ "test": "npm run build && node --test test/init.test.mjs test/login.test.mjs",
41
41
  "prepack": "npm run build"
42
42
  },
43
43
  "dependencies": {
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: sekura
3
3
  description: Use when the user wants a GitHub repository scanned for vulnerabilities, wants to read scan findings or a report, or wants to put a repository on a recurring Sekura scan. Covers the sekura MCP tools and the rule that every scan and plan is quoted first and started only after the user agrees.
4
+ argument-hint: "[help | repos | scan <owner/repo> | scans | scan-status <scan> | report <scan> | always <owner/repo>]"
4
5
  ---
5
6
 
6
7
  # Sekura
@@ -9,7 +10,58 @@ Sekura is an AI red team as a service. It scans a GitHub repository the user has
9
10
  granted it, exploits what it finds to prove each finding, and delivers a report.
10
11
  The `sekura` MCP server (https://sekura.ai/mcp) exposes it to this tool. Signing in is the
11
12
  user's GitHub login on sekura.ai: if a tool answers that the user is signed out,
12
- tell them to run `/mcp`, choose `sekura`, and approve in the browser.
13
+ tell them to run `npx sekura@latest login` in a terminal (it signs in once and
14
+ gives every tool the key), or to run `/mcp`, choose `sekura`, and approve in the
15
+ browser.
16
+
17
+ ## Commands
18
+
19
+ When the user types `/sekura` followed by arguments (here: `$ARGUMENTS`), read
20
+ the first word:
21
+
22
+ - `help`, `-h`, `--help` or `?`: reply with the help text below exactly as written,
23
+ and call no tool.
24
+ - nothing: call `getAccount` and `listRepositories`, summarise who is signed in,
25
+ whether the free first scan is available, and the repositories Sekura can scan,
26
+ then end with "Type `/sekura help` for commands."
27
+ - `repos`: `listRepositories`.
28
+ - `scan <owner/repo>`: `startScan` for that repository. This answers with a quote;
29
+ the two-step rule below applies.
30
+ - `scans`: `listScans`.
31
+ - `scan-status <scan>`: `getScan`. `findings <scan>`: `getScanFindings`.
32
+ - `report <scan>`: `getReportLink`.
33
+ - `always <owner/repo>`: `startAlways`, quoted first like a scan.
34
+ - `plans`: `getPlans`. `billing`: `getBilling`.
35
+ - anything else: treat the whole text as an ordinary request about Sekura.
36
+
37
+ The help text:
38
+
39
+ ```
40
+ Sekura: AI red team scans for your GitHub repositories
41
+
42
+ /sekura account, free scan and repositories at a glance
43
+ /sekura repos repositories Sekura can scan
44
+ /sekura scan <owner/repo> quote a scan; it starts only after you agree
45
+ /sekura scans your scans and their status
46
+ /sekura scan-status <scan> one scan's progress
47
+ /sekura findings <scan> a scan's findings, with proof
48
+ /sekura report <scan> link to the report
49
+ /sekura always <owner/repo> quote a recurring Sekura Always plan
50
+ /sekura plans repositories on a recurring plan
51
+ /sekura billing what is being billed
52
+ /sekura help this list
53
+
54
+ Every scan and plan is quoted first (price and authorization) and started
55
+ only after you say yes. Your free first scan is started on sekura.ai:
56
+ https://sekura.ai/app/start
57
+
58
+ Setup, in a terminal:
59
+ npx sekura@latest login sign in again, or pick another repository
60
+ npx sekura@latest status what is connected on this machine
61
+
62
+ Add repositories: https://sekura.ai/app (the Sekura GitHub App)
63
+ Documentation: https://sekura.ai/connect/
64
+ ```
13
65
 
14
66
  ## Tools
15
67