@steercards/agent-tools 0.1.1 → 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 +46 -8
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +34 -2
- package/dist/install.js +3 -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 +2 -1
- package/skills/steercards/SKILL.md +6 -0
- package/skills/steercards/references/collaboration.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`을 실행합니다.
|
|
@@ -16,10 +44,10 @@ Steercards의 전체 MCP 도구를 CLI와 에이전트에서 사용할 수 있
|
|
|
16
44
|
```sh
|
|
17
45
|
npm ci
|
|
18
46
|
npm run build
|
|
19
|
-
node /absolute/path/agent-tools/dist/cli.js setup --client codex-app --cwd /absolute/path/project --local
|
|
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
|
|
@@ -32,7 +60,7 @@ STEERCARDS_MCP_URL=https://steercards.com/api/mcp
|
|
|
32
60
|
클라이언트에서 필요한 MCP 승인과 재시작·다시 연결을 마친 뒤 확인합니다.
|
|
33
61
|
|
|
34
62
|
```sh
|
|
35
|
-
node /absolute/path/agent-tools/dist/cli.js doctor --cwd /absolute/path/project --connect
|
|
63
|
+
node -- /absolute/path/agent-tools/dist/cli.js doctor --cwd /absolute/path/project --connect
|
|
36
64
|
```
|
|
37
65
|
|
|
38
66
|
`doctor`는 키 값을 출력하지 않습니다. `--connect`를 추가하면 원격 서버에 연결하고 워크스페이스 목록을 조회하는 읽기 전용 검사를 수행합니다. 생략하면 로컬 설정만 확인합니다.
|
|
@@ -42,7 +70,7 @@ node /absolute/path/agent-tools/dist/cli.js doctor --cwd /absolute/path/project
|
|
|
42
70
|
처음 설치할 때와 새 버전으로 업데이트할 때 같은 명령을 사용합니다.
|
|
43
71
|
|
|
44
72
|
```sh
|
|
45
|
-
npx -y @steercards/agent-tools@latest setup --client codex-app --cwd /absolute/path/project
|
|
73
|
+
npx -y -- @steercards/agent-tools@latest setup --client codex-app --cwd /absolute/path/project
|
|
46
74
|
```
|
|
47
75
|
|
|
48
76
|
실행한 패키지 버전의 MCP 설정과 Skill을 함께 적용합니다. npx 기반 MCP 실행 명령은 해당 버전에 고정되므로 Skill과 같은 버전을 사용합니다. 같은 버전·옵션으로 다시 실행하면 변경 없이 성공합니다. 기존 설치에서는 생략한 실행 옵션(`--local`, `--mode`, `--url`, `--env-file`)을 설치 기록에서 유지합니다. `--local` 설치는 새 소스를 받은 뒤 `npm ci`, `npm run build`를 실행하고 같은 `setup --local` 명령을 다시 실행하세요.
|
|
@@ -50,7 +78,7 @@ npx -y @steercards/agent-tools@latest setup --client codex-app --cwd /absolute/p
|
|
|
50
78
|
소스의 `--local` 설치를 npm 실행 방식으로 전환할 때는 `--npx`를 지정합니다. 이후 MCP 설정은 실행한 패키지 버전의 npx 명령을 사용합니다.
|
|
51
79
|
|
|
52
80
|
```sh
|
|
53
|
-
npx -y @steercards/agent-tools@latest setup --client codex-app --cwd /absolute/path/project --npx
|
|
81
|
+
npx -y -- @steercards/agent-tools@latest setup --client codex-app --cwd /absolute/path/project --npx
|
|
54
82
|
```
|
|
55
83
|
|
|
56
84
|
전역 설치를 선택했다면 패키지를 업데이트한 뒤 `setup`을 다시 실행합니다.
|
|
@@ -60,7 +88,7 @@ npm install --global @steercards/agent-tools@latest
|
|
|
60
88
|
steercards setup --client codex-app --cwd /absolute/path/project
|
|
61
89
|
```
|
|
62
90
|
|
|
63
|
-
이하 `steercards` 명령은 전역 설치를 기준으로 합니다. 소스 빌드 사용자는 `node /absolute/path/agent-tools/dist/cli.js`로 대체하면 됩니다.
|
|
91
|
+
이하 `steercards` 명령은 전역 설치를 기준으로 합니다. 소스 빌드 사용자는 `node -- /absolute/path/agent-tools/dist/cli.js`로 대체하면 됩니다.
|
|
64
92
|
|
|
65
93
|
## 환경 설정
|
|
66
94
|
|
|
@@ -74,6 +102,15 @@ steercards setup --client codex-app --cwd /absolute/path/project
|
|
|
74
102
|
|
|
75
103
|
이미 설정된 프로세스 환경 변수가 `.env`보다 우선합니다. 기본 `.env`는 지정한 디렉터리에서만 찾으며 상위 디렉터리를 탐색하지 않습니다. dotenv 값에 셸 명령이나 `${VARIABLE}` 확장을 수행하지 않습니다. 원격 URL은 HTTPS를 사용하며 로컬 개발용 loopback 주소만 HTTP를 허용합니다. URL의 인증 정보·쿼리·fragment는 허용하지 않습니다.
|
|
76
104
|
|
|
105
|
+
프로젝트 밖에서도 상대 파일을 선택할 수 있습니다.
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
steercards doctor --cwd /absolute/path/project --env-file alternate.env
|
|
109
|
+
npx -y -- @steercards/agent-tools@latest doctor --cwd /absolute/path/project --env-file alternate.env
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`npx`에서는 패키지 이름 앞의 `--`를 유지하세요. Node 22 일부 버전은 프로그램 뒤의 `--env-file`도 시작 전에 검사하므로 이 경계가 필요합니다. 직접 Node로 실행할 때도 위 예시처럼 스크립트 경로 앞에 `--`를 넣습니다. 설치된 `steercards`와 새로 생성한 MCP 설정은 이 경계를 포함합니다. 기존 MCP 설정은 업데이트 후 `setup`을 다시 실행해 갱신하세요. Windows에서는 Node 22·24에 포함된 npm의 CMD·PowerShell shim을 사용합니다.
|
|
113
|
+
|
|
77
114
|
GUI 앱은 터미널과 다른 디렉터리·환경에서 시작될 수 있습니다. 설정을 생성할 때 `--cwd`에 실제 프로젝트의 절대 경로를 지정하세요. 여러 프로젝트를 사용하면 서버 이름과 `--cwd`를 프로젝트별로 구분하세요. 키는 MCP 설정에 복사하지 않고 해당 프로젝트의 `.env`에 둡니다.
|
|
78
115
|
|
|
79
116
|
## 클라이언트 설치·업데이트
|
|
@@ -102,6 +139,7 @@ Claude Desktop 자동 설치는 macOS와 Windows를 지원합니다. MCP 설정
|
|
|
102
139
|
|
|
103
140
|
```sh
|
|
104
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]
|
|
105
143
|
```
|
|
106
144
|
|
|
107
145
|
`--dry-run`은 파일을 쓰지 않고 예정된 작업만 보여줍니다. 키를 출력하거나 연결 검사를 수행하지 않습니다. `setup`은 코드 클라이언트를 프로젝트 범위에 설치하며 `--global`을 지원하지 않습니다. `--env-file`, `--url`, `--mode`는 생성할 MCP 실행 옵션에도 반영됩니다. 같은 버전에서도 옵션을 바꾸면 해당 설정을 갱신합니다. `--local`과 `--npx`는 함께 사용할 수 없으며 `--npx`는 `setup` 전용 옵션입니다.
|
|
@@ -180,11 +218,11 @@ Claude Code, Codex 앱·CLI, Cursor, Claude Desktop 각각의 네이티브 UI
|
|
|
180
218
|
npm ci
|
|
181
219
|
npm run check
|
|
182
220
|
npm test
|
|
183
|
-
npm run
|
|
221
|
+
npm run test:package
|
|
184
222
|
npm pack --dry-run
|
|
185
223
|
```
|
|
186
224
|
|
|
187
|
-
CI는 Linux·macOS·Windows의 Node 22·24에서 같은 검사를 실행합니다.
|
|
225
|
+
CI는 Linux·macOS·Windows의 Node 22·24에서 같은 검사를 실행합니다. `test:package`는 빌드한 tarball을 별도 프로젝트에 설치하고 실제 bin·npx 및 생성된 MCP 명령을 실행합니다. Windows에서는 CMD와 PowerShell shim도 검사합니다. 의존성은 lockfile과 `npm ci`의 캐시를 재사용하며 실제 Agent Key가 필요하지 않습니다. 기여 방법은 [CONTRIBUTING.md](CONTRIBUTING.md), 보안 보고는 [SECURITY.md](SECURITY.md)를 참고하세요.
|
|
188
226
|
|
|
189
227
|
공개 소스는 [velody-ai/steercards-agent-tools](https://github.com/velody-ai/steercards-agent-tools)에서 관리합니다. 안정 버전 GitHub Release를 게시하면 `publish.yml`이 태그·패키지 버전과 main 포함 여부를 확인하고 검사를 다시 실행한 뒤 npm에 게시합니다. 인증은 GitHub OIDC trusted publishing을 사용하며 공개 패키지에 provenance를 연결합니다.
|
|
190
228
|
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env -S node --
|
|
2
2
|
export {};
|
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env -S node --
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { resolve } from 'node:path';
|
|
@@ -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') {
|
package/dist/install.js
CHANGED
|
@@ -14,7 +14,9 @@ export function clientConfig(client, options = {}) {
|
|
|
14
14
|
if (!['compact', 'full'].includes(mode))
|
|
15
15
|
throw new Error('Mode must be compact or full.');
|
|
16
16
|
const cwd = resolve(options.cwd ?? process.cwd());
|
|
17
|
-
|
|
17
|
+
// Node 22 pre-scans --env-file even after the script path. Protect both the
|
|
18
|
+
// local Node process and the outer npx process before passing CLI options.
|
|
19
|
+
const args = options.local ? ['--', join(packageRoot, 'dist', 'cli.js')] : ['-y', '--', `${packageInfo.name}@${packageInfo.version}`];
|
|
18
20
|
args.push('serve', '--cwd', cwd, '--mode', mode);
|
|
19
21
|
if (options.envFile)
|
|
20
22
|
args.push('--env-file', resolve(cwd, options.envFile));
|
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steercards/agent-tools",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Progressive MCP discovery, CLI and agent skills for Steercards",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"scripts": {
|
|
29
29
|
"build": "tsc",
|
|
30
30
|
"test": "node --import tsx --test test/*.test.ts",
|
|
31
|
+
"test:package": "npm run build && node --test test/package.test.mjs",
|
|
31
32
|
"check": "tsc --noEmit",
|
|
32
33
|
"prepack": "npm run build"
|
|
33
34
|
},
|
|
@@ -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})`.
|
|
@@ -14,6 +14,12 @@ Only one of `before`, `after`, `pageCursor`, `refreshKeys` is allowed per call.
|
|
|
14
14
|
|
|
15
15
|
`request_human_check` names a real workspace member, prod/local environment and action/expected-result steps. It creates an item in the person's check inbox. Optional pass/fail business-status transitions must reflect the intended task. Query `list_human_checks` / `get_human_check` for actual verdicts and evidence. Creating the request is not a passed check, and "human check" is not a board status to invent.
|
|
16
16
|
|
|
17
|
+
Describe the live tool before constructing a request. Verification can optionally provide `resultOptions: [{id,label,description?,verdict}]`: 2–6 ordered choices shared by every step. IDs are unique ASCII letters/digits/underscore/hyphen (1–64 characters), labels are 1–120 characters and descriptions at most 500. Each verdict explicitly means `pass`, `fail` or `inconclusive`; never infer meaning from the label. Omit resultOptions to retain legacy pass/fail choices. Explicit null and mixing resultOptions with a question are invalid.
|
|
18
|
+
|
|
19
|
+
For example, use `works / Normal operation / pass`, `partial / Partly working / fail`, and `unable / Cannot verify / inconclusive`. Any failed step makes the result `failed`; otherwise any inconclusive step makes it `answered`; only all-pass becomes `passed`. A fail requires a memo. Inconclusive permits an optional memo and never means success. Verification `followUps` uses `onPassed`, `onFailed` and `onAnswered` once after aggregation. Omitted follow-ups keep the card state; inconclusive never falls back to legacy onFailStatus. For per-choice moves, use a single-selection question instead.
|
|
20
|
+
|
|
21
|
+
The assigned person chooses in the web inbox, saves each step, then explicitly submits the final response. Selection and step saving do not finalize the check or create the completion comment/webhook. Do not answer on the person's behalf. Read `steps[].selectedOptionId` and `selectedOptionSnapshot` for original ID, label, description and verdict; final `response` preserves resultOptions and steps. Processed history, comments and `human_check.completed` retain the original choice. Inspect actual `followUpResult` as current card state, permissions or workflow may block a requested move.
|
|
22
|
+
|
|
17
23
|
`set_agent_status` / `clear_agent_status` expose working/input-wait/error. A waiting question can create a pinned agent comment; it is not equivalent to a step-by-step human check. Working status needs refresh at least every ten minutes. Check claim fences when the card is claim-managed.
|
|
18
24
|
|
|
19
25
|
## Reserve shared resources
|