@steercards/agent-tools 0.1.2 → 0.1.3
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/CONTRIBUTING.md +1 -1
- package/README.md +30 -1
- package/dist/cli.js +33 -1
- package/dist/setup-credentials.d.ts +22 -0
- package/dist/setup-credentials.js +326 -0
- package/dist/setup-with-code.d.ts +40 -0
- package/dist/setup-with-code.js +196 -0
- package/dist/setup.d.ts +6 -0
- package/dist/setup.js +28 -1
- package/package.json +1 -1
- package/skills/steercards/SKILL.md +6 -0
package/CONTRIBUTING.md
CHANGED
|
@@ -25,7 +25,7 @@ npm pack --dry-run
|
|
|
25
25
|
- `serve`의 stdout은 MCP 메시지 전용입니다. 진단 로그는 stderr로 출력하고 키를 노출하지 않습니다.
|
|
26
26
|
- `setup`은 MCP 설정과 Skill을 함께 설치·업데이트합니다. Steercards 외 설정·주석·사용자 파일은 보존하고 관리 항목에 사용자 수정이 있으면 쓰기 전에 중단합니다. 임시 프로젝트와 격리한 홈 디렉터리에서 첫 설치, 재실행, 이전 버전 업데이트, 충돌, dry run과 쓰기 실패 복구를 검증하세요.
|
|
27
27
|
- `.steercards/setup/` 기록의 버전·옵션·해시를 다음 업데이트의 기준으로 사용합니다. 실제 개인 설치 기록과 ZIP은 테스트 fixture나 커밋에 넣지 마세요.
|
|
28
|
-
- `setup`은 키 없이 실행하고 `.env`를
|
|
28
|
+
- 코드 옵션 없는 `setup`은 키 없이 실행하고 `.env`를 변경하지 않습니다. 일회용 코드 모드에서는 로컬 충돌·추적·쓰기 점검을 교환 전에 완료하고, 저장 뒤 설치 실패 시 `.env`를 보존합니다. HTTP fixture와 packed bin/npx·Windows stdin 검사에서 코드·키 비노출 및 재교환 금지를 확인하세요. `config`는 설정 조각만 출력하며 개별 `skill install`은 사용자 수정 파일을 덮어쓰지 않습니다.
|
|
29
29
|
|
|
30
30
|
변경 제안은 [공개 저장소](https://github.com/velody-ai/steercards-agent-tools)에 PR로 올려주세요. 보안 문제는 공개 이슈에 올리지 말고 [SECURITY.md](SECURITY.md)를 따릅니다.
|
|
31
31
|
|
package/README.md
CHANGED
|
@@ -9,6 +9,34 @@ Steercards의 전체 MCP 도구를 CLI와 에이전트에서 사용할 수 있
|
|
|
9
9
|
|
|
10
10
|
패키지 이름은 `@steercards/agent-tools`, 실행 명령은 `steercards`입니다. npm에서 설치하거나 소스에서 빌드해 사용할 수 있습니다.
|
|
11
11
|
|
|
12
|
+
## 일회용 프롬프트로 연결
|
|
13
|
+
|
|
14
|
+
Steercards의 Agent 연결 화면에서 클라이언트·OS·Key 설정을 선택하고 설치 프롬프트를 복사해 사용할 프로젝트의 에이전트에게 전달하세요. 프롬프트에는 10분 동안 한 번 사용할 설치 코드가 들어갑니다. 설치기는 코드를 실제 Agent Key로 교환하여 현재 프로젝트의 `.env`에만 저장하고, `.gitignore` 보호·MCP 설정·Skill 설치와 읽기 전용 연결 검사를 수행합니다. 장기 키를 프롬프트에 붙여넣을 필요가 없습니다.
|
|
15
|
+
|
|
16
|
+
직접 실행하는 경우 아래 코드 자리를 앱에서 발급한 설치 코드로 바꿉니다. POSIX 셸 예시:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npx -y -- @steercards/agent-tools@latest setup --client codex-app --cwd . --env-file .env --url https://steercards.com/api/mcp --setup-code-stdin <<'STEERCARDS_SETUP_CODE'
|
|
20
|
+
<일회용 설치 코드>
|
|
21
|
+
STEERCARDS_SETUP_CODE
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Windows PowerShell에서는 같은 코드를 here-string으로 전달합니다.
|
|
25
|
+
|
|
26
|
+
```powershell
|
|
27
|
+
@'
|
|
28
|
+
<일회용 설치 코드>
|
|
29
|
+
'@ | npx -y -- @steercards/agent-tools@latest setup --client codex-app --cwd . --env-file .env --url https://steercards.com/api/mcp --setup-code-stdin
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`--cwd .`는 실행한 현재 프로젝트를 뜻하며 생략해도 같습니다. `--client`는 선택한 클라이언트로 바꾸고, 다른 서버를 사용한다면 그 서버의 MCP URL을 명시하세요. 설치 코드와 발급된 키는 명령 인자·출력·설치 기록에 넣지 않습니다. 자동 연결 검사는 호출 프로세스의 다른 키보다 저장한 `.env`의 키를 사용합니다.
|
|
33
|
+
|
|
34
|
+
기존 `.env`의 다른 값·주석·개행을 보존합니다. 이미 Agent Key가 있으면 그대로 사용하고 코드를 교환하지 않습니다. 교체하려면 같은 setup 명령에 `--replace-agent-key`를 명시하세요. 이전 키를 서버에서 자동 폐기하지는 않습니다. 자동 쓰기는 프로젝트 루트 `.env`만 지원합니다. 이전 설치가 다른 env 파일을 참조하면 교환 전에 중단하므로 `--env-file .env`로 전환을 명시합니다.
|
|
35
|
+
|
|
36
|
+
`.env`가 Git에 추적되거나 파일·설정·Skill 충돌, symlink, 잠금·쓰기 문제가 있으면 교환 전에 중단합니다. `.gitignore`의 기존 negation 뒤에 보호 규칙을 보완합니다. `--dry-run`은 코드를 소비하거나 파일을 쓰지 않습니다.
|
|
37
|
+
|
|
38
|
+
키 저장 뒤 MCP·Skill 설치나 연결 검사에 실패해도 `.env`를 보존합니다. 충돌을 정리하고 코드 옵션 없이 일반 setup과 `doctor --connect`를 다시 실행하세요. 교환 응답 유실이나 키 저장 실패는 코드가 소비되었을 수 있으므로 앱에서 새 코드를 발급하고 사용하지 않는 발급 키를 폐기합니다. 실패 복구를 위해 키 원문을 채팅·로그에 붙여넣지 마세요. Desktop의 Skill ZIP 업로드와 클라이언트 재연결은 아래 안내를 따릅니다.
|
|
39
|
+
|
|
12
40
|
## 소스에서 설치
|
|
13
41
|
|
|
14
42
|
소스 디렉터리에서 빌드한 뒤 `setup --local`을 실행합니다.
|
|
@@ -19,7 +47,7 @@ npm run build
|
|
|
19
47
|
node -- /absolute/path/agent-tools/dist/cli.js setup --client codex-app --cwd /absolute/path/project --local
|
|
20
48
|
```
|
|
21
49
|
|
|
22
|
-
`setup
|
|
50
|
+
코드 옵션 없는 `setup`은 Steercards MCP 설정과 Skill을 키 없이 설치하며 `.env`를 변경하지 않습니다. 수동 연결에서는 설치 뒤 프로젝트에 `.env.example`을 참고한 `.env`를 만들고 실제 Agent Key를 입력하세요.
|
|
23
51
|
|
|
24
52
|
```dotenv
|
|
25
53
|
STEERCARDS_AGENT_KEY=your-agent-key
|
|
@@ -111,6 +139,7 @@ Claude Desktop 자동 설치는 macOS와 Windows를 지원합니다. MCP 설정
|
|
|
111
139
|
|
|
112
140
|
```sh
|
|
113
141
|
steercards setup [--client CLIENT] [--local | --npx] [--dry-run] [--cwd PATH] [--env-file PATH] [--url URL] [--mode compact|full]
|
|
142
|
+
[--setup-code-stdin] [--replace-agent-key]
|
|
114
143
|
```
|
|
115
144
|
|
|
116
145
|
`--dry-run`은 파일을 쓰지 않고 예정된 작업만 보여줍니다. 키를 출력하거나 연결 검사를 수행하지 않습니다. `setup`은 코드 클라이언트를 프로젝트 범위에 설치하며 `--global`을 지원하지 않습니다. `--env-file`, `--url`, `--mode`는 생성할 MCP 실행 옵션에도 반영됩니다. 같은 버전에서도 옵션을 바꾸면 해당 설정을 갱신합니다. `--local`과 `--npx`는 함께 사용할 수 없으며 `--npx`는 `setup` 전용 옵션입니다.
|
package/dist/cli.js
CHANGED
|
@@ -8,9 +8,11 @@ import { Runtime } from './runtime.js';
|
|
|
8
8
|
import { startServer } from './server.js';
|
|
9
9
|
import { clientConfig, installSkill, exportSkill, packageInfo } from './install.js';
|
|
10
10
|
import { setup } from './setup.js';
|
|
11
|
+
import { setupWithCode } from './setup-with-code.js';
|
|
11
12
|
const help = `Steercards agent tools ${packageInfo.version}
|
|
12
13
|
|
|
13
14
|
steercards setup [--client CLIENT] [--local | --npx] [--dry-run]
|
|
15
|
+
[--setup-code-stdin] [--replace-agent-key]
|
|
14
16
|
steercards serve [--mode compact|full]
|
|
15
17
|
steercards tools [query] [--limit 10]
|
|
16
18
|
steercards describe NAME
|
|
@@ -28,6 +30,9 @@ setup installs MCP configuration and the skill together; rerun to update both.
|
|
|
28
30
|
setup uses project scope (Desktop uses its user configuration and a project ZIP).
|
|
29
31
|
--npx explicitly switches an existing local setup to a pinned npm package.
|
|
30
32
|
--dry-run previews setup without writing files. No key is needed for setup.
|
|
33
|
+
--setup-code-stdin reads a one-use installation code, protects the project .env,
|
|
34
|
+
installs MCP and the skill, then checks the connection. Existing keys are kept.
|
|
35
|
+
--replace-agent-key explicitly replaces the project .env Agent Key.
|
|
31
36
|
config only prints a snippet and never edits host settings.
|
|
32
37
|
--local uses this installation's absolute Node/CLI paths (works before npm release).
|
|
33
38
|
Claude Desktop: upload the skill ZIP, use compact get_guide, or full guide resources.
|
|
@@ -64,6 +69,18 @@ async function stdinText() {
|
|
|
64
69
|
}
|
|
65
70
|
return Buffer.concat(chunks).toString('utf8');
|
|
66
71
|
}
|
|
72
|
+
async function installationCode() {
|
|
73
|
+
const chunks = [];
|
|
74
|
+
let size = 0;
|
|
75
|
+
for await (const chunk of process.stdin) {
|
|
76
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
77
|
+
size += bytes.length;
|
|
78
|
+
if (size > 1024)
|
|
79
|
+
throw new Error('Installation code input is too large.');
|
|
80
|
+
chunks.push(bytes);
|
|
81
|
+
}
|
|
82
|
+
return Buffer.concat(chunks).toString('utf8').replace(/\r?\n$/, '');
|
|
83
|
+
}
|
|
67
84
|
let secret = process.env.STEERCARDS_AGENT_KEY;
|
|
68
85
|
async function main() {
|
|
69
86
|
const { values, positionals } = parseArgs({ allowPositionals: true, strict: true, options: {
|
|
@@ -72,6 +89,7 @@ async function main() {
|
|
|
72
89
|
client: { type: 'string' }, global: { type: 'boolean' }, output: { type: 'string' },
|
|
73
90
|
limit: { type: 'string' }, connect: { type: 'boolean' }, local: { type: 'boolean' },
|
|
74
91
|
'dry-run': { type: 'boolean' }, npx: { type: 'boolean' },
|
|
92
|
+
'setup-code-stdin': { type: 'boolean' }, 'replace-agent-key': { type: 'boolean' },
|
|
75
93
|
help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' },
|
|
76
94
|
} });
|
|
77
95
|
if (values.version) {
|
|
@@ -95,6 +113,8 @@ async function main() {
|
|
|
95
113
|
}
|
|
96
114
|
if (values.global)
|
|
97
115
|
throw new Error('setup uses project scope; omit --global. Claude Desktop configuration is user-scoped.');
|
|
116
|
+
if (values['replace-agent-key'] && !values['setup-code-stdin'])
|
|
117
|
+
throw new Error('--replace-agent-key requires --setup-code-stdin.');
|
|
98
118
|
if (values.local && values.npx)
|
|
99
119
|
throw new Error('Choose only one of --local or --npx.');
|
|
100
120
|
let client = values.client;
|
|
@@ -114,9 +134,21 @@ async function main() {
|
|
|
114
134
|
if (!client)
|
|
115
135
|
throw new Error('No client selected. Nothing was installed.');
|
|
116
136
|
}
|
|
117
|
-
|
|
137
|
+
const setupOptions = { ...options, client, local: values.npx ? false : values.local, mode: values.mode, dryRun: values['dry-run'] };
|
|
138
|
+
if (values['setup-code-stdin']) {
|
|
139
|
+
const code = await installationCode();
|
|
140
|
+
secret = code;
|
|
141
|
+
const result = await setupWithCode({ ...setupOptions, code, replaceAgentKey: values['replace-agent-key'] });
|
|
142
|
+
output(result);
|
|
143
|
+
if (!result.ok)
|
|
144
|
+
process.exitCode = 1;
|
|
145
|
+
}
|
|
146
|
+
else
|
|
147
|
+
output(await setup(setupOptions));
|
|
118
148
|
return;
|
|
119
149
|
}
|
|
150
|
+
if (values['setup-code-stdin'] || values['replace-agent-key'])
|
|
151
|
+
throw new Error('Installation code options are only supported by setup.');
|
|
120
152
|
if (values['dry-run'] || values.npx)
|
|
121
153
|
throw new Error('--dry-run and --npx are only supported by setup.');
|
|
122
154
|
if (command === 'config') {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface CredentialOptions {
|
|
2
|
+
cwd?: string;
|
|
3
|
+
envFile?: string;
|
|
4
|
+
savedEnvFile?: string;
|
|
5
|
+
replaceAgentKey?: boolean;
|
|
6
|
+
dryRun?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** Holds only nonsecret metadata in enumerable properties. */
|
|
9
|
+
export interface ProjectCredentials {
|
|
10
|
+
cwd: string;
|
|
11
|
+
envFile: string;
|
|
12
|
+
needsExchange: boolean;
|
|
13
|
+
actions: string[];
|
|
14
|
+
verify(): Promise<void>;
|
|
15
|
+
protect(): Promise<void>;
|
|
16
|
+
save(token: string): Promise<void>;
|
|
17
|
+
readKey(): Promise<string>;
|
|
18
|
+
readMcpUrl(): Promise<string | undefined>;
|
|
19
|
+
close(): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
/** Preflight local credentials while holding a project lock shared by every client. */
|
|
22
|
+
export declare function prepareProjectCredentials(options?: CredentialOptions): Promise<ProjectCredentials>;
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { lstat, mkdir, open, readFile, realpath, rename, rm, rmdir } from 'node:fs/promises';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { parse } from 'dotenv';
|
|
7
|
+
const exec = promisify(execFile);
|
|
8
|
+
const keyName = 'STEERCARDS_AGENT_KEY';
|
|
9
|
+
const keyPattern = /^sc_agent_[A-Za-z0-9_-]{43}$/;
|
|
10
|
+
const missing = (error) => error.code === 'ENOENT';
|
|
11
|
+
async function noSymlink(path) {
|
|
12
|
+
let current = path;
|
|
13
|
+
while (true) {
|
|
14
|
+
try {
|
|
15
|
+
if ((await lstat(current)).isSymbolicLink())
|
|
16
|
+
throw new Error('Setup refuses a symbolic link in a credential path.');
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (!missing(error))
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
const parent = dirname(current);
|
|
23
|
+
if (parent === current)
|
|
24
|
+
return;
|
|
25
|
+
current = parent;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function snapshot(path) {
|
|
29
|
+
await noSymlink(path);
|
|
30
|
+
try {
|
|
31
|
+
const stat = await lstat(path);
|
|
32
|
+
if (!stat.isFile() || stat.nlink !== 1)
|
|
33
|
+
throw new Error('Credential and ignore files must be ordinary, unlinked files.');
|
|
34
|
+
if (stat.size > 1024 * 1024)
|
|
35
|
+
throw new Error('Credential or ignore file is too large to update safely.');
|
|
36
|
+
const bytes = await readFile(path);
|
|
37
|
+
try {
|
|
38
|
+
new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
throw new Error('Credential and ignore files must use UTF-8.');
|
|
42
|
+
}
|
|
43
|
+
return bytes;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (missing(error))
|
|
47
|
+
return null;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const same = (left, right) => left === null ? right === null : right !== null && left.equals(right);
|
|
52
|
+
const eol = (text) => text.includes('\r\n') ? '\r\n' : '\n';
|
|
53
|
+
/** Locate assignments without mistaking lines inside another multiline value for keys. */
|
|
54
|
+
function keyValue(text) {
|
|
55
|
+
const assignments = [];
|
|
56
|
+
let offset = text.charCodeAt(0) === 0xfeff ? 1 : 0;
|
|
57
|
+
while (offset < text.length) {
|
|
58
|
+
const lineEnd = text.indexOf('\n', offset);
|
|
59
|
+
const end = lineEnd < 0 ? text.length : lineEnd;
|
|
60
|
+
const line = text.slice(offset, end).replace(/\r$/, '');
|
|
61
|
+
const match = /^([ \t]*(?:export[ \t]+)?([\w.-]+)[ \t]*(?:=[ \t]*|:[ \t]+))(.*)$/.exec(line);
|
|
62
|
+
if (!match) {
|
|
63
|
+
offset = lineEnd < 0 ? text.length : lineEnd + 1;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const start = offset + match[1].length;
|
|
67
|
+
let valueEnd = start;
|
|
68
|
+
const quote = text[start];
|
|
69
|
+
if (["'", '"', '`'].includes(quote)) {
|
|
70
|
+
valueEnd = start + 1;
|
|
71
|
+
let closed = false;
|
|
72
|
+
for (; valueEnd < text.length; valueEnd++) {
|
|
73
|
+
if (text[valueEnd] !== quote)
|
|
74
|
+
continue;
|
|
75
|
+
let slashes = 0;
|
|
76
|
+
for (let i = valueEnd - 1; i > start && text[i] === '\\'; i--)
|
|
77
|
+
slashes++;
|
|
78
|
+
if (slashes % 2 === 0) {
|
|
79
|
+
valueEnd++;
|
|
80
|
+
closed = true;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!closed)
|
|
85
|
+
throw new Error('Environment file contains an unterminated quoted value. Review it before setup.');
|
|
86
|
+
const tailEnd = text.indexOf('\n', valueEnd);
|
|
87
|
+
const tail = text.slice(valueEnd, tailEnd < 0 ? text.length : tailEnd).replace(/\r$/, '');
|
|
88
|
+
if (!/^[ \t]*(?:#.*)?$/.test(tail))
|
|
89
|
+
throw new Error('Environment file contains an ambiguous quoted assignment. Review it before setup.');
|
|
90
|
+
offset = tailEnd < 0 ? text.length : tailEnd + 1;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const comment = match[3].indexOf('#');
|
|
94
|
+
valueEnd = start + (comment < 0 ? match[3] : match[3].slice(0, comment)).trimEnd().length;
|
|
95
|
+
offset = lineEnd < 0 ? text.length : lineEnd + 1;
|
|
96
|
+
}
|
|
97
|
+
if (match[2] === keyName) {
|
|
98
|
+
if (/[\r\n]/.test(text.slice(start, valueEnd)))
|
|
99
|
+
throw new Error('A multiline Agent Key assignment cannot be replaced automatically.');
|
|
100
|
+
const value = parse(`${keyName}=${text.slice(start, valueEnd)}`)[keyName] ?? '';
|
|
101
|
+
if (/\s/.test(value))
|
|
102
|
+
throw new Error('The existing Agent Key is invalid. Review its environment assignment before setup.');
|
|
103
|
+
assignments.push({ start, end: valueEnd, value });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (assignments.length > 1)
|
|
107
|
+
throw new Error('Environment file contains duplicate Agent Key assignments. Resolve them before setup.');
|
|
108
|
+
return assignments[0];
|
|
109
|
+
}
|
|
110
|
+
function withKey(text, token) {
|
|
111
|
+
const assignment = keyValue(text);
|
|
112
|
+
if (assignment)
|
|
113
|
+
return text.slice(0, assignment.start) + token + text.slice(assignment.end);
|
|
114
|
+
return text + (text && !text.endsWith('\n') ? eol(text) : '') + `${keyName}=${token}${eol(text)}`;
|
|
115
|
+
}
|
|
116
|
+
function withIgnore(text) {
|
|
117
|
+
const patterns = text.replace(/^\uFEFF/, '').split(/\r?\n/).map(line => line.trimEnd()).filter(line => line && !line.startsWith('#'));
|
|
118
|
+
if (patterns.at(-2) === '/.env' && patterns.at(-1) === '/.steercards/')
|
|
119
|
+
return text;
|
|
120
|
+
const newline = eol(text);
|
|
121
|
+
return text + (text && !text.endsWith('\n') ? newline : '') + `# Steercards local credentials${newline}/.env${newline}/.steercards/${newline}`;
|
|
122
|
+
}
|
|
123
|
+
/** Git's index takes precedence over ignore rules; never put a new secret in a tracked file. */
|
|
124
|
+
async function assertUntracked(cwd) {
|
|
125
|
+
let current = cwd;
|
|
126
|
+
let repository = false;
|
|
127
|
+
while (true) {
|
|
128
|
+
try {
|
|
129
|
+
await lstat(join(current, '.git'));
|
|
130
|
+
repository = true;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (!missing(error))
|
|
135
|
+
throw new Error('Unable to inspect Git tracking before setup.');
|
|
136
|
+
}
|
|
137
|
+
const parent = dirname(current);
|
|
138
|
+
if (parent === current)
|
|
139
|
+
break;
|
|
140
|
+
current = parent;
|
|
141
|
+
}
|
|
142
|
+
if (!repository)
|
|
143
|
+
return;
|
|
144
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')));
|
|
145
|
+
let output;
|
|
146
|
+
try {
|
|
147
|
+
({ stdout: output } = await exec('git', ['-C', cwd, 'ls-files', '-z', '--', ':(icase,literal).env'], { env, timeout: 10000, maxBuffer: 64 * 1024 }));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
throw new Error('Unable to verify Git tracking. Install Git or resolve the repository error before setup.');
|
|
151
|
+
}
|
|
152
|
+
if (output)
|
|
153
|
+
throw new Error('The project .env is tracked by Git. Stop tracking it yourself before installing credentials.');
|
|
154
|
+
}
|
|
155
|
+
async function atomicWrite(path, bytes, mode = 0o600) {
|
|
156
|
+
await noSymlink(path);
|
|
157
|
+
const stage = join(dirname(path), `.steercards-${randomUUID()}.tmp`);
|
|
158
|
+
try {
|
|
159
|
+
const file = await open(stage, 'wx', mode);
|
|
160
|
+
try {
|
|
161
|
+
await file.writeFile(bytes);
|
|
162
|
+
await file.sync();
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
await file.close();
|
|
166
|
+
}
|
|
167
|
+
await rename(stage, path);
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
await rm(stage, { force: true });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** Preflight local credentials while holding a project lock shared by every client. */
|
|
174
|
+
export async function prepareProjectCredentials(options = {}) {
|
|
175
|
+
let cwd;
|
|
176
|
+
try {
|
|
177
|
+
cwd = await realpath(resolve(options.cwd ?? process.cwd()));
|
|
178
|
+
if (!(await lstat(cwd)).isDirectory())
|
|
179
|
+
throw new Error();
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
throw new Error('The selected project directory must already exist.');
|
|
183
|
+
}
|
|
184
|
+
const envFile = join(cwd, '.env');
|
|
185
|
+
if (options.envFile !== undefined && resolve(cwd, options.envFile) !== envFile)
|
|
186
|
+
throw new Error('Automatic credentials only support the project .env. Use --env-file .env.');
|
|
187
|
+
if (options.envFile === undefined && options.savedEnvFile !== undefined && resolve(cwd, options.savedEnvFile) !== envFile)
|
|
188
|
+
throw new Error('This installation uses another environment file. Explicitly select --env-file .env to switch before redeeming a code.');
|
|
189
|
+
const ignoreFile = join(cwd, '.gitignore');
|
|
190
|
+
const lockPath = join(cwd, '.steercards', 'setup', 'credentials.lock');
|
|
191
|
+
const created = [];
|
|
192
|
+
let locked = false;
|
|
193
|
+
let closed = false;
|
|
194
|
+
const lockBytes = Buffer.from(`${process.pid}:${randomUUID()}\n`);
|
|
195
|
+
const ensureDirectory = async (path) => {
|
|
196
|
+
await noSymlink(path);
|
|
197
|
+
try {
|
|
198
|
+
if (!(await lstat(path)).isDirectory())
|
|
199
|
+
throw new Error('Expected a setup directory.');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (!missing(error))
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
await ensureDirectory(dirname(path));
|
|
207
|
+
try {
|
|
208
|
+
await mkdir(path, { mode: 0o700 });
|
|
209
|
+
created.push(path);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
if (error.code !== 'EEXIST')
|
|
213
|
+
throw error;
|
|
214
|
+
await noSymlink(path);
|
|
215
|
+
if (!(await lstat(path)).isDirectory())
|
|
216
|
+
throw new Error('Expected a setup directory.');
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
const close = async () => {
|
|
220
|
+
if (closed)
|
|
221
|
+
return;
|
|
222
|
+
closed = true;
|
|
223
|
+
if (locked && same(lockBytes, await snapshot(lockPath)))
|
|
224
|
+
await rm(lockPath);
|
|
225
|
+
for (const path of created.reverse()) {
|
|
226
|
+
try {
|
|
227
|
+
await rmdir(path);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
if (!missing(error) && !['ENOTEMPTY', 'EEXIST'].includes(error.code ?? ''))
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
try {
|
|
236
|
+
await noSymlink(lockPath);
|
|
237
|
+
if (!options.dryRun) {
|
|
238
|
+
await ensureDirectory(dirname(lockPath));
|
|
239
|
+
try {
|
|
240
|
+
const file = await open(lockPath, 'wx', 0o600);
|
|
241
|
+
locked = true;
|
|
242
|
+
try {
|
|
243
|
+
await file.writeFile(lockBytes);
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
await file.close();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
if (error.code === 'EEXIST')
|
|
251
|
+
throw new Error('Another setup holds the project credential lock. Wait for it to finish.');
|
|
252
|
+
throw new Error('Unable to acquire the project credential lock. Check directory permissions.');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
let envBefore = await snapshot(envFile);
|
|
256
|
+
let ignoreBefore = await snapshot(ignoreFile);
|
|
257
|
+
const existing = keyValue(envBefore?.toString('utf8') ?? '')?.value;
|
|
258
|
+
const needsExchange = !existing || options.replaceAgentKey === true;
|
|
259
|
+
const ignoreAfter = Buffer.from(withIgnore(ignoreBefore?.toString('utf8') ?? ''));
|
|
260
|
+
const ignoreMode = ignoreBefore ? (await lstat(ignoreFile)).mode & 0o777 : 0o644;
|
|
261
|
+
if (needsExchange && envBefore && ((await lstat(envFile)).mode & 0o200) === 0)
|
|
262
|
+
throw new Error('The project environment file is read-only. Change its permissions before setup.');
|
|
263
|
+
if (!same(ignoreBefore, ignoreAfter) && ignoreBefore && (ignoreMode & 0o200) === 0)
|
|
264
|
+
throw new Error('The project ignore file is read-only. Change its permissions before setup.');
|
|
265
|
+
await assertUntracked(cwd);
|
|
266
|
+
const verify = async () => {
|
|
267
|
+
if (closed)
|
|
268
|
+
throw new Error('Credential setup is already closed.');
|
|
269
|
+
await assertUntracked(cwd);
|
|
270
|
+
if (!same(envBefore, await snapshot(envFile)) || !same(ignoreBefore, await snapshot(ignoreFile)))
|
|
271
|
+
throw new Error('The project environment or ignore file changed during setup. Review it and retry.');
|
|
272
|
+
};
|
|
273
|
+
if (!options.dryRun && (needsExchange || !same(ignoreBefore, ignoreAfter))) {
|
|
274
|
+
// A real same-directory create/rename probe catches permissions before consuming a code.
|
|
275
|
+
const probe = join(cwd, `.steercards-probe-${randomUUID()}.tmp`);
|
|
276
|
+
try {
|
|
277
|
+
await atomicWrite(probe, Buffer.alloc(0));
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
throw new Error('The project directory does not allow safe credential writes.');
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
await rm(probe, { force: true });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const actions = [...(!same(ignoreBefore, ignoreAfter) ? ['protect project .env and .steercards in .gitignore'] : []), ...(needsExchange ? [existing ? 'replace project Agent Key' : 'save project Agent Key'] : ['keep existing project Agent Key'])];
|
|
287
|
+
return {
|
|
288
|
+
cwd, envFile, needsExchange, actions, verify, close,
|
|
289
|
+
async protect() {
|
|
290
|
+
if (options.dryRun)
|
|
291
|
+
throw new Error('A dry run cannot write files.');
|
|
292
|
+
await verify();
|
|
293
|
+
if (!same(ignoreBefore, ignoreAfter)) {
|
|
294
|
+
await atomicWrite(ignoreFile, ignoreAfter, ignoreMode);
|
|
295
|
+
ignoreBefore = ignoreAfter;
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
async save(token) {
|
|
299
|
+
if (options.dryRun)
|
|
300
|
+
throw new Error('A dry run cannot save credentials.');
|
|
301
|
+
if (!needsExchange)
|
|
302
|
+
throw new Error('The existing project Agent Key must be preserved.');
|
|
303
|
+
if (!keyPattern.test(token))
|
|
304
|
+
throw new Error('The exchanged Agent Key has an invalid format.');
|
|
305
|
+
await verify();
|
|
306
|
+
if (!same(ignoreBefore, ignoreAfter))
|
|
307
|
+
throw new Error('Protect the project environment file before saving credentials.');
|
|
308
|
+
const bytes = Buffer.from(withKey(envBefore?.toString('utf8') ?? '', token));
|
|
309
|
+
await atomicWrite(envFile, bytes);
|
|
310
|
+
envBefore = bytes;
|
|
311
|
+
},
|
|
312
|
+
async readKey() {
|
|
313
|
+
await verify();
|
|
314
|
+
const value = keyValue(envBefore?.toString('utf8') ?? '')?.value;
|
|
315
|
+
if (!value)
|
|
316
|
+
throw new Error('The project environment file has no Agent Key.');
|
|
317
|
+
return value;
|
|
318
|
+
},
|
|
319
|
+
async readMcpUrl() { await verify(); return parse(envBefore?.toString('utf8') ?? '').STEERCARDS_MCP_URL; },
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
await close();
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type SetupOptions, type SetupReport, type SetupDependencies } from './setup.js';
|
|
2
|
+
export declare const INSTALL_CODE_PATTERN: RegExp;
|
|
3
|
+
type Receipt = {
|
|
4
|
+
installationId: string;
|
|
5
|
+
keyId: string;
|
|
6
|
+
profileIds: string[];
|
|
7
|
+
};
|
|
8
|
+
export interface CodeSetupReport {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
setup: SetupReport | null;
|
|
11
|
+
credentials: {
|
|
12
|
+
status: 'not-saved' | 'saved' | 'kept' | 'dry-run';
|
|
13
|
+
envFile?: string;
|
|
14
|
+
};
|
|
15
|
+
doctor: {
|
|
16
|
+
connected: boolean | null;
|
|
17
|
+
keySource?: 'env-file';
|
|
18
|
+
toolCount?: number;
|
|
19
|
+
};
|
|
20
|
+
receipt?: Receipt;
|
|
21
|
+
error?: string;
|
|
22
|
+
nextSteps: string[];
|
|
23
|
+
}
|
|
24
|
+
export interface CodeSetupOptions extends SetupOptions {
|
|
25
|
+
code: string;
|
|
26
|
+
replaceAgentKey?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** Isolated HTTP, filesystem failure and diagnostic fixtures; never needed by normal callers. */
|
|
29
|
+
export interface CodeSetupDependencies {
|
|
30
|
+
fetch?: typeof fetch;
|
|
31
|
+
setup?: SetupDependencies;
|
|
32
|
+
doctor?: (cwd: string, url: string) => Promise<{
|
|
33
|
+
connected: boolean;
|
|
34
|
+
keySource: 'env-file';
|
|
35
|
+
toolCount?: number;
|
|
36
|
+
}>;
|
|
37
|
+
}
|
|
38
|
+
/** Redeem only after local setup is ready; keep saved credentials if later configuration fails. */
|
|
39
|
+
export declare function setupWithCode(options: CodeSetupOptions, dependencies?: CodeSetupDependencies): Promise<CodeSetupReport>;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { DEFAULT_MCP_URL, loadConfig, validateMcpUrl } from './config.js';
|
|
2
|
+
import { Runtime } from './runtime.js';
|
|
3
|
+
import { setup } from './setup.js';
|
|
4
|
+
import { prepareProjectCredentials } from './setup-credentials.js';
|
|
5
|
+
import { realpath, stat } from 'node:fs/promises';
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
export const INSTALL_CODE_PATTERN = /^sc_install_[A-Za-z0-9_-]{43}$/;
|
|
8
|
+
const tokenPattern = /^sc_agent_[A-Za-z0-9_-]{43}$/;
|
|
9
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
10
|
+
const errors = {
|
|
11
|
+
AGENT_INSTALL_CODE_NOT_FOUND: 'Installation code was not found. Create a new code in Steercards.',
|
|
12
|
+
AGENT_INSTALL_CODE_EXPIRED: 'Installation code expired. Create a new code in Steercards.',
|
|
13
|
+
AGENT_INSTALL_CODE_REVOKED: 'Installation code was revoked. Create a new code in Steercards.',
|
|
14
|
+
AGENT_INSTALL_CODE_CONSUMED: 'Installation code was already used. Check the issued Agent Key in Steercards before creating a new code.',
|
|
15
|
+
AGENT_INSTALL_CODE_PROFILE_INVALID: 'The selected profiles are no longer available. Create a new installation code.',
|
|
16
|
+
AGENT_INSTALL_CODE_OWNER_INVALID: 'The installation owner is no longer available. Sign in to Steercards and create a new code.',
|
|
17
|
+
RATE_LIMITED: 'Too many installation attempts. Wait before creating another request.',
|
|
18
|
+
};
|
|
19
|
+
const recovery = 'The code may have been consumed. Create a new code and remove any unused issued Agent Key in Steercards. Never paste the Agent Key into chat.';
|
|
20
|
+
async function boundedJson(response) {
|
|
21
|
+
if (!response.headers.get('content-type')?.toLowerCase().includes('application/json')) {
|
|
22
|
+
await response.body?.cancel();
|
|
23
|
+
throw new Error('Installation service returned an invalid response.');
|
|
24
|
+
}
|
|
25
|
+
const reader = response.body?.getReader();
|
|
26
|
+
if (!reader)
|
|
27
|
+
throw new Error('Installation service returned an empty response.');
|
|
28
|
+
let size = 0;
|
|
29
|
+
const chunks = [];
|
|
30
|
+
try {
|
|
31
|
+
while (true) {
|
|
32
|
+
const { value, done } = await reader.read();
|
|
33
|
+
if (done)
|
|
34
|
+
break;
|
|
35
|
+
size += value.byteLength;
|
|
36
|
+
if (size > 64 * 1024) {
|
|
37
|
+
await reader.cancel();
|
|
38
|
+
throw new Error('Installation service response is too large.');
|
|
39
|
+
}
|
|
40
|
+
chunks.push(value);
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new Error('Installation service returned invalid JSON.');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
reader.releaseLock();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function exchange(code, url, fetcher) {
|
|
54
|
+
let response;
|
|
55
|
+
try {
|
|
56
|
+
response = await fetcher(new URL('/api/agent-setup/exchange', url), {
|
|
57
|
+
method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
58
|
+
body: JSON.stringify({ code }), redirect: 'error', signal: AbortSignal.timeout(15000),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new Error(`Unable to confirm installation code exchange. ${recovery}`);
|
|
63
|
+
}
|
|
64
|
+
let value;
|
|
65
|
+
try {
|
|
66
|
+
value = await boundedJson(response);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new Error(`Installation service returned an invalid or incomplete response. ${recovery}`);
|
|
70
|
+
}
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
const errorCode = value?.error?.code;
|
|
73
|
+
const message = typeof errorCode === 'string' && Object.hasOwn(errors, errorCode) ? errors[errorCode] : undefined;
|
|
74
|
+
throw new Error(message ?? `Installation service rejected the request (HTTP ${response.status}). ${recovery}`);
|
|
75
|
+
}
|
|
76
|
+
if (!value || typeof value.token !== 'string' || !tokenPattern.test(value.token)
|
|
77
|
+
|| typeof value.installationId !== 'string' || !uuidPattern.test(value.installationId)
|
|
78
|
+
|| !value.key || typeof value.key.id !== 'string' || !uuidPattern.test(value.key.id) || !Array.isArray(value.profileIds)
|
|
79
|
+
|| value.profileIds.length > 30 || value.profileIds.some((id) => typeof id !== 'string' || !uuidPattern.test(id))
|
|
80
|
+
|| new Set(value.profileIds).size !== value.profileIds.length)
|
|
81
|
+
throw new Error(`Installation service returned invalid credentials. ${recovery}`);
|
|
82
|
+
return { token: value.token, receipt: { installationId: value.installationId, keyId: value.key.id, profileIds: value.profileIds } };
|
|
83
|
+
}
|
|
84
|
+
async function doctor(cwd, url) {
|
|
85
|
+
// Explicitly inspect the stored file, regardless of the invoking agent's environment.
|
|
86
|
+
const config = await loadConfig({ cwd, envFile: '.env', url, env: {} });
|
|
87
|
+
const runtime = new Runtime(config);
|
|
88
|
+
const timeout = setTimeout(() => { void runtime.close().catch(() => { }); }, 15000);
|
|
89
|
+
timeout.unref();
|
|
90
|
+
try {
|
|
91
|
+
const tools = await runtime.listTools();
|
|
92
|
+
const result = await runtime.callTool('list_workspaces', {});
|
|
93
|
+
let failed = result.isError === true || result.structuredContent?.ok === false;
|
|
94
|
+
for (const item of result.content)
|
|
95
|
+
if (item.type === 'text') {
|
|
96
|
+
try {
|
|
97
|
+
if (JSON.parse(item.text)?.ok === false)
|
|
98
|
+
failed = true;
|
|
99
|
+
}
|
|
100
|
+
catch { /* Ordinary text is allowed. */ }
|
|
101
|
+
}
|
|
102
|
+
return { connected: !failed, keySource: 'env-file', toolCount: tools.length };
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
clearTimeout(timeout);
|
|
106
|
+
await runtime.close();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Redeem only after local setup is ready; keep saved credentials if later configuration fails. */
|
|
110
|
+
export async function setupWithCode(options, dependencies = {}) {
|
|
111
|
+
const report = { ok: false, setup: null, credentials: { status: 'not-saved' }, doctor: { connected: null }, nextSteps: [] };
|
|
112
|
+
let credentials;
|
|
113
|
+
let token;
|
|
114
|
+
let attemptedExchange = false;
|
|
115
|
+
let mcpUrl;
|
|
116
|
+
try {
|
|
117
|
+
if (!INSTALL_CODE_PATTERN.test(options.code))
|
|
118
|
+
throw new Error('Installation code has an invalid format. Copy a fresh setup prompt from Steercards.');
|
|
119
|
+
try {
|
|
120
|
+
if (!(await stat(await realpath(resolve(options.cwd ?? process.cwd())))).isDirectory())
|
|
121
|
+
throw new Error();
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
throw new Error('The selected project directory must already exist.');
|
|
125
|
+
}
|
|
126
|
+
mcpUrl = validateMcpUrl(options.url ?? DEFAULT_MCP_URL);
|
|
127
|
+
report.setup = await setup({ ...options, url: mcpUrl }, {
|
|
128
|
+
...dependencies.setup,
|
|
129
|
+
async afterPreflight(context) {
|
|
130
|
+
credentials = await prepareProjectCredentials({ cwd: context.cwd, envFile: options.envFile, savedEnvFile: context.envFile, replaceAgentKey: options.replaceAgentKey, dryRun: context.dryRun });
|
|
131
|
+
report.credentials.envFile = credentials.envFile;
|
|
132
|
+
const savedUrl = await credentials.readMcpUrl();
|
|
133
|
+
if (savedUrl && new URL(validateMcpUrl(savedUrl)).origin !== new URL(mcpUrl).origin && options.url === undefined)
|
|
134
|
+
throw new Error('The project uses another MCP server. Explicitly select its --url before redeeming an installation code.');
|
|
135
|
+
if (context.dryRun) {
|
|
136
|
+
report.credentials.status = 'dry-run';
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
await credentials.protect();
|
|
140
|
+
if (!credentials.needsExchange) {
|
|
141
|
+
report.credentials.status = 'kept';
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
await credentials.verify();
|
|
145
|
+
attemptedExchange = true;
|
|
146
|
+
const claimed = await exchange(options.code, mcpUrl, dependencies.fetch ?? fetch);
|
|
147
|
+
token = claimed.token;
|
|
148
|
+
report.receipt = claimed.receipt;
|
|
149
|
+
try {
|
|
150
|
+
await credentials.save(token);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
throw new Error(`The issued Agent Key could not be saved safely. ${recovery}`);
|
|
154
|
+
}
|
|
155
|
+
report.credentials.status = 'saved';
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
report.setup.actions.unshift(...credentials.actions);
|
|
159
|
+
report.setup.nextSteps = report.setup.nextSteps.filter(step => !step.startsWith('Set STEERCARDS_AGENT_KEY'));
|
|
160
|
+
if (options.dryRun) {
|
|
161
|
+
report.ok = true;
|
|
162
|
+
report.nextSteps = ['Run setup again without --dry-run to complete installation.'];
|
|
163
|
+
return report;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
report.doctor = await (dependencies.doctor ?? doctor)(credentials.cwd, mcpUrl);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
report.doctor = { connected: false, keySource: 'env-file' };
|
|
170
|
+
}
|
|
171
|
+
report.ok = report.doctor.connected === true;
|
|
172
|
+
report.nextSteps = [...report.setup.nextSteps, ...(!report.ok ? ['Credentials and setup were preserved. Run doctor --connect after checking the MCP server. Do not redeem the code again.'] : [])];
|
|
173
|
+
if (!report.ok)
|
|
174
|
+
report.error = 'Setup completed, but the connection check did not succeed.';
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
let message = error instanceof Error ? error.message : 'Installation failed.';
|
|
178
|
+
for (const secret of [options.code, token])
|
|
179
|
+
if (secret)
|
|
180
|
+
message = message.split(secret).join('[REDACTED]');
|
|
181
|
+
report.error = message;
|
|
182
|
+
report.nextSteps = report.credentials.status === 'saved' || report.credentials.status === 'kept'
|
|
183
|
+
? ['The project .env was preserved. Fix the configuration conflict and rerun setup without an installation code, then doctor --connect.']
|
|
184
|
+
: [attemptedExchange ? recovery : 'Resolve the local setup issue and rerun the prompt before the code expires.'];
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
try {
|
|
188
|
+
await credentials?.close();
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
report.ok = false;
|
|
192
|
+
report.error = 'Setup could not release its local lock. Review the project setup lock before retrying.';
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return report;
|
|
196
|
+
}
|
package/dist/setup.d.ts
CHANGED
|
@@ -29,6 +29,12 @@ export interface SetupDependencies {
|
|
|
29
29
|
version?: string;
|
|
30
30
|
beforeCommit?: () => Promise<void>;
|
|
31
31
|
beforeWrite?: (path: string, index: number) => Promise<void>;
|
|
32
|
+
/** Runs under the config lock after all destinations are checked; dry runs never redeem codes. */
|
|
33
|
+
afterPreflight?: (context: {
|
|
34
|
+
cwd: string;
|
|
35
|
+
envFile?: string;
|
|
36
|
+
dryRun: boolean;
|
|
37
|
+
}) => Promise<void>;
|
|
32
38
|
}
|
|
33
39
|
/** Configure MCP and the matching skill together, preserving unrelated client data. */
|
|
34
40
|
export declare function setup(options: SetupOptions, dependencies?: SetupDependencies): Promise<SetupReport>;
|
package/dist/setup.js
CHANGED
|
@@ -349,7 +349,11 @@ export async function setup(options, dependencies = {}) {
|
|
|
349
349
|
? [`Restart Claude Desktop to load the MCP server.`, `Upload ${zipPath} in Claude's skill settings. Repeat the upload after a skill update.`, 'Set STEERCARDS_AGENT_KEY in the project .env or the referenced env file before using MCP.']
|
|
350
350
|
: ['Restart or reload your client to load the MCP server and skill.', 'Set STEERCARDS_AGENT_KEY in the project .env or the referenced env file before using MCP.'];
|
|
351
351
|
const report = { client, version, status: options.dryRun ? 'dry-run' : plan.size ? previous ? 'updated' : 'installed' : 'unchanged', paths, actions, nextSteps };
|
|
352
|
-
if (options.dryRun
|
|
352
|
+
if (options.dryRun) {
|
|
353
|
+
await dependencies.afterPreflight?.({ cwd, envFile: effective.envFile, dryRun: true });
|
|
354
|
+
return report;
|
|
355
|
+
}
|
|
356
|
+
if (!plan.size && !dependencies.afterPreflight)
|
|
353
357
|
return report;
|
|
354
358
|
// This is an ordinary-failure transaction, not a crash-atomic multi-file commit.
|
|
355
359
|
const createdDirectories = [];
|
|
@@ -398,6 +402,29 @@ export async function setup(options, dependencies = {}) {
|
|
|
398
402
|
}
|
|
399
403
|
if (skillPath)
|
|
400
404
|
await checkSkillTree(skillPath);
|
|
405
|
+
if (dependencies.afterPreflight) {
|
|
406
|
+
// Exercise destination directories before a one-use credential is consumed.
|
|
407
|
+
for (const parent of new Set([...plan.keys()].map(path => dirname(path)))) {
|
|
408
|
+
await ensureDirectory(parent);
|
|
409
|
+
const probe = join(parent, `.steercards-write-probe-${randomUUID()}.tmp`);
|
|
410
|
+
try {
|
|
411
|
+
await atomicWrite(probe, Buffer.alloc(0), 0o600, ensureDirectory);
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
throw new Error('A setup destination does not allow safe writes. Check its directory permissions.');
|
|
415
|
+
}
|
|
416
|
+
finally {
|
|
417
|
+
await rm(probe, { force: true });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
for (const [path, before] of baselines) {
|
|
421
|
+
if (!sameSnapshot(before, await snapshot(path)))
|
|
422
|
+
throw new Error('A setup destination changed during preflight. No credential was requested.');
|
|
423
|
+
if (plan.has(path) && before && (before.mode & 0o200) === 0)
|
|
424
|
+
throw new Error('A setup destination is read-only. Change its permissions before installing credentials.');
|
|
425
|
+
}
|
|
426
|
+
await dependencies.afterPreflight({ cwd, envFile: effective.envFile, dryRun: false });
|
|
427
|
+
}
|
|
401
428
|
let index = 0;
|
|
402
429
|
for (const [path, bytes] of plan) {
|
|
403
430
|
await dependencies.beforeWrite?.(path, index++);
|
package/package.json
CHANGED
|
@@ -7,6 +7,12 @@ description: Manage Steercards workspaces, boards, cards, files and agent collab
|
|
|
7
7
|
|
|
8
8
|
Use the connected Steercards toolkit MCP or its `steercards` CLI. Authentication comes from `STEERCARDS_AGENT_KEY`, including a local dotenv file configured for the toolkit. Keep credentials out of tool arguments, logs and committed files. GUI hosts can use an absolute env-file path; their working directory may differ from the project.
|
|
9
9
|
|
|
10
|
+
## Connecting from an installation prompt
|
|
11
|
+
|
|
12
|
+
When the user provides a Steercards installation prompt, run its `setup --setup-code-stdin` command in the intended project with `--cwd . --env-file .env` and the prompt's explicit MCP `--url`. Pass the short-lived code through stdin. The installer exchanges it once, saves the Agent Key only in the project `.env`, protects Git ignore rules, installs MCP and this skill, and checks the connection. Report the safe installation summary; never print the resulting key or inspect the exchange body for chat output.
|
|
13
|
+
|
|
14
|
+
An existing project key is kept without consuming the code. Replace it only when requested, using `--replace-agent-key`. Automatic credential writes support only the project root `.env`. Local configuration/skill conflicts or a tracked `.env` must be resolved before redeeming. If credentials were saved but installation or doctor failed, preserve `.env` and rerun normal setup/doctor without a code. After an uncertain exchange or failed key save, ask for a fresh app-generated prompt and direct the user to remove unused issued keys in Steercards; do not replay the code or request the long-lived key in chat. Client restart/reconnect and Desktop Skill ZIP upload may still be needed.
|
|
15
|
+
|
|
10
16
|
## Discover only what the task needs
|
|
11
17
|
|
|
12
18
|
1. Search capabilities: `steercards_search_tools({query: "card context", limit: 5})`.
|