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 +24 -10
- package/dist/api.js +42 -0
- package/dist/auth.js +172 -0
- package/dist/catalogue.js +18 -12
- package/dist/cli.js +9 -5
- package/dist/commands/init.js +46 -13
- package/dist/commands/login.js +73 -0
- package/dist/commands/status.js +27 -6
- package/dist/commands/uninstall.js +6 -0
- package/dist/guide.js +215 -0
- package/dist/plan.js +3 -1
- package/dist/repo.js +14 -0
- package/dist/ui.js +1 -0
- package/dist/writers.js +23 -8
- package/package.json +2 -2
- package/skills/sekura/SKILL.md +53 -1
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
|
|
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. **
|
|
16
|
-
4. **
|
|
17
|
-
5. **
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
27
|
-
npx sekura@latest
|
|
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
|
|
53
|
-
|
|
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, '<')));
|
|
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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
// `sekura init`: detect, choose, review, apply, verify
|
|
2
|
-
//
|
|
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.
|
|
73
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
+
});
|
package/dist/commands/status.js
CHANGED
|
@@ -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
|
|
16
|
-
|
|
17
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|
package/skills/sekura/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|