sprag-cli 3.69.0 → 3.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +5 -2
- package/README.ko.md +16 -0
- package/README.md +16 -0
- package/bin/cli.js +53 -4
- package/docs/CODEX.ko.md +3 -2
- package/docs/CODEX.md +5 -4
- package/docs/TEAM.ko.md +174 -0
- package/docs/TEAM.md +175 -0
- package/package.json +5 -1
- package/presets/agents.json +118 -0
- package/presets/doc2md/convert.py +1 -1
- package/presets/ratchet-rules.json +12 -2
- package/src/agents.js +3 -1
- package/src/cli-entry.js +40 -0
- package/src/codex/delegation.js +8 -5
- package/src/codex/hooks.js +25 -12
- package/src/codex/panel-setup.js +2 -1
- package/src/codex/panel.js +2 -1
- package/src/codex/route-scan.js +6 -4
- package/src/commands/brief.js +2 -0
- package/src/commands/codex-delegate.js +3 -3
- package/src/commands/codex.js +6 -5
- package/src/commands/harness.js +3 -4
- package/src/commands/install.js +19 -18
- package/src/commands/presets.js +158 -0
- package/src/commands/profile-map.js +2 -2
- package/src/commands/route-scan.js +25 -10
- package/src/commands/team.js +334 -0
- package/src/commands/update-check.js +2 -2
- package/src/cost.js +6 -2
- package/src/cowork-sync.js +5 -4
- package/src/debug.js +3 -2
- package/src/delegation-agents.js +49 -49
- package/src/delegation-guard.js +3 -2
- package/src/doc2md.cjs +4 -3
- package/src/env.cjs +7 -0
- package/src/env.js +19 -0
- package/src/first-run-note.js +3 -2
- package/src/harness-templates.js +10 -0
- package/src/harness.js +4 -14
- package/src/hook.cjs +4 -3
- package/src/model-rules.js +58 -29
- package/src/preset-sign.js +106 -0
- package/src/preset-sync.js +133 -0
- package/src/presets-source.js +209 -0
- package/src/prompt.js +2 -1
- package/src/route-caps.js +3 -3
- package/src/route-inject.js +24 -15
- package/src/route-scan.js +39 -15
- package/src/seed-rules.js +14 -16
- package/src/shared-model-rules.js +1 -1
- package/src/star-note.js +3 -2
- package/src/statusline-guard.js +144 -0
- package/src/stdin-payload.js +33 -4
- package/src/team-prompts.js +353 -0
- package/src/team-ratchet.js +276 -0
- package/src/team-sync.js +625 -0
- package/src/update-check.js +3 -2
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* team — `sprag team <create|join|leave|status|sync|rules|propose>`.
|
|
3
|
+
*
|
|
4
|
+
* The opt-in team layer. `join` shows exactly what will be uploaded (daily
|
|
5
|
+
* aggregates only) and asks before enrolling. `sync --background` is what the
|
|
6
|
+
* SessionStart hook's detached child runs: auto-join from SPRAG_TEAM_TOKEN,
|
|
7
|
+
* then upload and pull, printing nothing. All network access goes through
|
|
8
|
+
* rpc() in src/team-sync.js. Exit codes: 0 ok, 1 error, 2 usage.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createInterface } from 'node:readline';
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { userInfo } from 'node:os';
|
|
14
|
+
import { userLanguage } from '../config.js';
|
|
15
|
+
import { pendingPrompts } from '../team-prompts.js';
|
|
16
|
+
import {
|
|
17
|
+
loadTeamState, deviceInfo, isJoined, joinTeam, leaveTeam, syncTeam, autoJoin, aggregateDaily, rpc,
|
|
18
|
+
teamRulesCachePath, teamSyncDisabled, requestSignInCode, verifySignInCode, createTeam, isSeatLimitError,
|
|
19
|
+
} from '../team-sync.js';
|
|
20
|
+
|
|
21
|
+
const T = {
|
|
22
|
+
en: {
|
|
23
|
+
usage: [
|
|
24
|
+
'Usage: sprag team <command>',
|
|
25
|
+
' create [--email <e>] [--name <n>] [--code <6 digits>] [--org <org-id>] [--no-join] create a team (you become the owner)',
|
|
26
|
+
' join [token] [--name <n>] [--yes] join a team (token or SPRAG_TEAM_TOKEN)',
|
|
27
|
+
' leave leave the team and stop uploading',
|
|
28
|
+
' status [--show-payload] membership, last sync, what is sent',
|
|
29
|
+
' sync [--force] [--rules-only] upload aggregates, prompts (Enterprise capture) and pull rules now',
|
|
30
|
+
' (--rules-only: pull organization and team rules only, upload nothing)',
|
|
31
|
+
' rules show cached organization and team rules',
|
|
32
|
+
' propose <text> [--scope ratchet|model] propose a rule to the team',
|
|
33
|
+
].join('\n'),
|
|
34
|
+
willSend: 'Joining uploads daily aggregates only, per local day:\n - spend (USD)\n - savings (USD)\n - delegation run counts\n - model-family share\nNo prompts, file paths or transcripts are sent.',
|
|
35
|
+
asName: (n) => `Display name: ${n}`,
|
|
36
|
+
ask: 'Join? [y/N] ',
|
|
37
|
+
needYes: 'Not a terminal. Re-run with --yes to confirm: sprag team join <token> --yes',
|
|
38
|
+
noToken: 'Missing invite token. Pass it as an argument or set SPRAG_TEAM_TOKEN.',
|
|
39
|
+
aborted: 'Not joined.',
|
|
40
|
+
joined: (n) => `Joined team "${n}". Check it with: sprag team status`,
|
|
41
|
+
seatLimit: 'This organization has no free seat. Ask your organization owner or admin to add seats, then join again.',
|
|
42
|
+
org: 'Organization',
|
|
43
|
+
seats: (u, l) => `${u}/${l} seats used`,
|
|
44
|
+
orgCreated: (n, u, l) => `Organization: ${n}${u != null && l != null ? ` (${u}/${l} seats used)` : ''}`,
|
|
45
|
+
badOrg: '--org needs an organization id.',
|
|
46
|
+
left: 'Left the team. Nothing is uploaded any more.',
|
|
47
|
+
notJoined: 'Not in a team. Join with: sprag team join <token>',
|
|
48
|
+
team: 'Team', plan: 'Plan', capture: 'Prompt capture', name: 'Display name',
|
|
49
|
+
lastSync: (t, n) => `Last sync: ${t} (${n} rows)`,
|
|
50
|
+
neverSynced: 'Last sync: never',
|
|
51
|
+
lastError: 'Last error',
|
|
52
|
+
on: 'on', off: 'off',
|
|
53
|
+
lastPrompts: (t, n) => `Last prompt upload: ${t} (${n} prompts)`,
|
|
54
|
+
noPrompts: 'Last prompt upload: never',
|
|
55
|
+
pendingPrompts: (n) => `Prompts waiting to upload: ${n}`,
|
|
56
|
+
pendingExample: (e) => `Example (redacted): ${e.at} · ${e.project || '-'} · ${e.text}`,
|
|
57
|
+
disabled: 'Team sync is disabled (SPRAG_NO_TEAM_SYNC=1).',
|
|
58
|
+
skipped: (s) => `Skipped: ${s}`,
|
|
59
|
+
synced: (u, r, p) => `Synced: ${u} day rows uploaded, ${r} rules${p ? `, ${p} prompts` : ''}.`,
|
|
60
|
+
rulesPulled: (r) => `Rules updated: ${r} organization and team rules. A new session applies them.`,
|
|
61
|
+
noRules: 'No organization or team rules yet.',
|
|
62
|
+
proposed: (id) => `Proposed. Id: ${id}`,
|
|
63
|
+
needText: 'Missing rule text. Usage: sprag team propose <text> [--scope ratchet|model]',
|
|
64
|
+
badScope: 'Scope must be ratchet or model.',
|
|
65
|
+
needJoin: 'Join a team first: sprag team join <token>',
|
|
66
|
+
createUsage: 'Usage: sprag team create --email <e> --name "<team name>" [--code <6 digits>] [--no-join]',
|
|
67
|
+
badEmail: 'That does not look like an email address.',
|
|
68
|
+
badCode: 'The code is 6 digits, like 123456.',
|
|
69
|
+
needEmail: 'Missing --email. Usage: sprag team create --email <e> --name "<team name>"',
|
|
70
|
+
needName: 'Missing --name. Usage: sprag team create --email <e> --name "<team name>" --code <6 digits>',
|
|
71
|
+
alreadyJoined: (n) => `This machine is already in team "${n}". Run \`sprag team leave\` first, or pass --no-join.`,
|
|
72
|
+
askEmail: 'Your email: ',
|
|
73
|
+
askCode: 'Code from the email (6 digits): ',
|
|
74
|
+
askTeamName: (d) => `Team name [${d}]: `,
|
|
75
|
+
codeSent: (e) => `A 6-digit code was sent to ${e} (valid 15 minutes).`,
|
|
76
|
+
rerun: (e, n) => `Then run: sprag team create --email ${e} --name "${n}" --code <code>`,
|
|
77
|
+
created: (n) => `Created team "${n}".`,
|
|
78
|
+
inviteToken: 'Invite token',
|
|
79
|
+
memberCmd: 'Member command',
|
|
80
|
+
orgLine: 'Organization env',
|
|
81
|
+
dashboard: (e) => `Dashboard: https://sprag.io/team/ (sign in with ${e})`,
|
|
82
|
+
noJoinNote: 'This machine did not join (--no-join).',
|
|
83
|
+
syncFailed: (e) => `Team created, but the first sync failed: ${e}`,
|
|
84
|
+
},
|
|
85
|
+
ko: {
|
|
86
|
+
usage: [
|
|
87
|
+
'사용법: sprag team <명령>',
|
|
88
|
+
' create [--email <e>] [--name <이름>] [--code <6자리>] [--org <조직 ID>] [--no-join] 팀 만들기 (만든 사람이 조직 오너)',
|
|
89
|
+
' join [토큰] [--name <이름>] [--yes] 팀에 참여 (토큰 또는 SPRAG_TEAM_TOKEN)',
|
|
90
|
+
' leave 팀에서 나가고 업로드 중단',
|
|
91
|
+
' status [--show-payload] 참여 상태, 마지막 동기화, 보내는 내용',
|
|
92
|
+
' sync [--force] [--rules-only] 지금 집계를 올리고 규칙을 받기',
|
|
93
|
+
' (--rules-only: 조직·팀 규칙만 받고 아무것도 올리지 않음)',
|
|
94
|
+
' rules 받아 둔 조직·팀 규칙 보기',
|
|
95
|
+
' propose <내용> [--scope ratchet|model] 팀에 규칙 제안',
|
|
96
|
+
].join('\n'),
|
|
97
|
+
willSend: '참여하면 로컬 날짜별 하루 단위 집계만 올라갑니다.\n - 지출(USD)\n - 절감(USD)\n - 위임 실행 횟수\n - 모델 계열 비중\n프롬프트, 파일 경로, 대화 기록은 보내지 않습니다.',
|
|
98
|
+
asName: (n) => `표시 이름: ${n}`,
|
|
99
|
+
ask: '참여할까요? [y/N] ',
|
|
100
|
+
needYes: '터미널이 아닙니다. 확인하려면 --yes 를 붙여 다시 실행하세요: sprag team join <토큰> --yes',
|
|
101
|
+
noToken: '초대 토큰이 없습니다. 인자로 넘기거나 SPRAG_TEAM_TOKEN 을 설정하세요.',
|
|
102
|
+
aborted: '참여하지 않았습니다.',
|
|
103
|
+
joined: (n) => `팀 "${n}" 에 참여했습니다. 확인: sprag team status`,
|
|
104
|
+
seatLimit: '이 조직에 남은 좌석이 없습니다. 조직 오너나 어드민에게 좌석 추가를 요청한 뒤 다시 참여하세요',
|
|
105
|
+
org: '조직',
|
|
106
|
+
seats: (u, l) => `좌석 ${u}/${l} 사용 중`,
|
|
107
|
+
orgCreated: (n, u, l) => `조직: ${n}${u != null && l != null ? ` (좌석 ${u}/${l} 사용 중)` : ''}`,
|
|
108
|
+
badOrg: '--org 에는 조직 ID 가 필요합니다',
|
|
109
|
+
left: '팀에서 나왔습니다. 이제 아무것도 업로드하지 않습니다.',
|
|
110
|
+
notJoined: '참여한 팀이 없습니다. 참여하려면: sprag team join <토큰>',
|
|
111
|
+
team: '팀', plan: '플랜', capture: '프롬프트 수집', name: '표시 이름',
|
|
112
|
+
lastSync: (t, n) => `마지막 동기화: ${t} (${n}행)`,
|
|
113
|
+
neverSynced: '마지막 동기화: 아직 없음',
|
|
114
|
+
lastError: '마지막 오류',
|
|
115
|
+
on: '켜짐', off: '꺼짐',
|
|
116
|
+
lastPrompts: (t, n) => `마지막 프롬프트 업로드: ${t} (${n}건)`,
|
|
117
|
+
noPrompts: '마지막 프롬프트 업로드: 아직 없음',
|
|
118
|
+
pendingPrompts: (n) => `업로드 대기 중인 프롬프트: ${n}건`,
|
|
119
|
+
pendingExample: (e) => `예시 (비밀값 가림): ${e.at} · ${e.project || '-'} · ${e.text}`,
|
|
120
|
+
disabled: '팀 동기화가 꺼져 있습니다 (SPRAG_NO_TEAM_SYNC=1).',
|
|
121
|
+
skipped: (s) => `건너뜀: ${s}`,
|
|
122
|
+
synced: (u, r, p) => `동기화 완료: ${u}일치 집계 업로드, 규칙 ${r}개${p ? `, 프롬프트 ${p}건` : ''}.`,
|
|
123
|
+
rulesPulled: (r) => `규칙을 받았습니다: 조직·팀 규칙 ${r}개. 새 세션부터 적용됩니다.`,
|
|
124
|
+
noRules: '아직 조직이나 팀 규칙이 없습니다.',
|
|
125
|
+
proposed: (id) => `제안했습니다. ID: ${id}`,
|
|
126
|
+
needText: '규칙 내용이 없습니다. 사용법: sprag team propose <내용> [--scope ratchet|model]',
|
|
127
|
+
badScope: '범위는 ratchet 또는 model 이어야 합니다.',
|
|
128
|
+
needJoin: '먼저 팀에 참여하세요: sprag team join <토큰>',
|
|
129
|
+
createUsage: '사용법: sprag team create --email <e> --name "<팀 이름>" [--code <6자리>] [--no-join]',
|
|
130
|
+
badEmail: '이메일 주소 형식이 아닙니다',
|
|
131
|
+
badCode: '코드는 123456 같은 6자리 숫자입니다',
|
|
132
|
+
needEmail: '--email 이 없습니다. 사용법: sprag team create --email <e> --name "<팀 이름>"',
|
|
133
|
+
needName: '--name 이 없습니다. 사용법: sprag team create --email <e> --name "<팀 이름>" --code <6자리>',
|
|
134
|
+
alreadyJoined: (n) => `이 PC는 이미 팀 "${n}" 에 참여 중입니다. 먼저 \`sprag team leave\` 를 실행하거나 --no-join 을 붙이세요`,
|
|
135
|
+
askEmail: '이메일: ',
|
|
136
|
+
askCode: '메일로 받은 코드 (6자리): ',
|
|
137
|
+
askTeamName: (d) => `팀 이름 [${d}]: `,
|
|
138
|
+
codeSent: (e) => `${e} 로 6자리 코드를 보냈습니다 (15분간 유효)`,
|
|
139
|
+
rerun: (e, n) => `받은 코드로 다시 실행하세요: sprag team create --email ${e} --name "${n}" --code <코드>`,
|
|
140
|
+
created: (n) => `팀 "${n}" 을 만들었습니다`,
|
|
141
|
+
inviteToken: '초대 토큰',
|
|
142
|
+
memberCmd: '멤버용 명령',
|
|
143
|
+
orgLine: '조직 환경 변수',
|
|
144
|
+
dashboard: (e) => `대시보드: https://sprag.io/team/ (${e} 로 로그인)`,
|
|
145
|
+
noJoinNote: '이 PC는 참여하지 않았습니다 (--no-join)',
|
|
146
|
+
syncFailed: (e) => `팀은 만들어졌지만 첫 동기화에 실패했습니다: ${e}`,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `sprag team rules` lines: `- [org · model] text`, organization rules first.
|
|
152
|
+
* Rows without a level (older servers) are team rules. Removed ids are skipped.
|
|
153
|
+
*/
|
|
154
|
+
export function formatRuleLines(cache) {
|
|
155
|
+
const removed = new Set(Array.isArray(cache?.removed) ? cache.removed : []);
|
|
156
|
+
const rules = (Array.isArray(cache?.rules) ? cache.rules : [])
|
|
157
|
+
.filter((r) => r && typeof r.text === 'string' && r.text.trim() && !removed.has(r.id));
|
|
158
|
+
const org = rules.filter((r) => r.level === 'org');
|
|
159
|
+
const team = rules.filter((r) => r.level !== 'org');
|
|
160
|
+
return [...org, ...team].map((r) =>
|
|
161
|
+
`- [${r.level === 'org' ? 'org' : 'team'} · ${r.scope === 'model' ? 'model' : 'ratchet'}] ${r.text.trim().replace(/\s*\n\s*/g, ' ')}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function prompt(question) {
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
167
|
+
rl.question(question, (a) => { rl.close(); resolve(a.trim()); });
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function confirm(question) {
|
|
172
|
+
return /^y(es)?$/i.test(await prompt(question));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
176
|
+
const CODE_RE = /^\d{6}$/;
|
|
177
|
+
|
|
178
|
+
/** `sprag team create`: sign in by emailed code, insert the team, join as first member. */
|
|
179
|
+
async function createFlow({ m, out, hasFlag, getArg, version, fetch, interactive, ask }) {
|
|
180
|
+
const noJoin = hasFlag('--no-join');
|
|
181
|
+
const st0 = loadTeamState();
|
|
182
|
+
if (isJoined(st0) && !noJoin) { console.error(m.alreadyJoined(st0.teamName || '')); return process.exit(1); }
|
|
183
|
+
|
|
184
|
+
const org = getArg('--org');
|
|
185
|
+
if (hasFlag('--org') && (!org || org.startsWith('--'))) { console.error(m.badOrg); return process.exit(2); }
|
|
186
|
+
let email = getArg('--email');
|
|
187
|
+
let name = getArg('--name');
|
|
188
|
+
const code = getArg('--code');
|
|
189
|
+
if (code !== undefined && code !== null && !CODE_RE.test(String(code))) { console.error(m.badCode); return process.exit(2); }
|
|
190
|
+
if (!email && interactive) email = await ask(m.askEmail);
|
|
191
|
+
if (!email) { console.error(m.needEmail); return process.exit(2); }
|
|
192
|
+
if (!EMAIL_RE.test(email)) { console.error(m.badEmail); return process.exit(2); }
|
|
193
|
+
|
|
194
|
+
let osName = 'my';
|
|
195
|
+
try { osName = userInfo().username || 'my'; } catch { /* no passwd entry */ }
|
|
196
|
+
const defaultName = `${osName}'s team`;
|
|
197
|
+
|
|
198
|
+
let useCode = code;
|
|
199
|
+
if (!useCode) {
|
|
200
|
+
if (!interactive) {
|
|
201
|
+
await requestSignInCode({ email, fetch });
|
|
202
|
+
out(m.codeSent(email));
|
|
203
|
+
out(m.rerun(email, name || defaultName));
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
await requestSignInCode({ email, fetch });
|
|
207
|
+
out(m.codeSent(email));
|
|
208
|
+
useCode = await ask(m.askCode);
|
|
209
|
+
if (!CODE_RE.test(useCode)) { console.error(m.badCode); return process.exit(2); }
|
|
210
|
+
}
|
|
211
|
+
if (!name && interactive) name = (await ask(m.askTeamName(defaultName))) || defaultName;
|
|
212
|
+
if (!name) { console.error(m.needName); return process.exit(2); }
|
|
213
|
+
|
|
214
|
+
const { accessToken, userId } = await verifySignInCode({ email, code: useCode, fetch });
|
|
215
|
+
const team = await createTeam({ name, accessToken, userId, orgId: org || null, fetch });
|
|
216
|
+
out(m.created(team.name || name));
|
|
217
|
+
if (team.orgName && noJoin) out(m.orgCreated(team.orgName, null, null));
|
|
218
|
+
out(`${m.inviteToken}: ${team.inviteToken}`);
|
|
219
|
+
out(`${m.memberCmd}: npm i -g sprag-cli@latest && sprag team join ${team.inviteToken}`);
|
|
220
|
+
out(`${m.orgLine}: export SPRAG_TEAM_TOKEN=${team.inviteToken}`);
|
|
221
|
+
out(m.dashboard(email));
|
|
222
|
+
|
|
223
|
+
if (noJoin) { out(m.noJoinNote); return undefined; }
|
|
224
|
+
let display = 'member';
|
|
225
|
+
try { display = userInfo().username || 'member'; } catch { /* no passwd entry */ }
|
|
226
|
+
const joined = await joinTeam({ token: team.inviteToken, displayName: display, fetch, version });
|
|
227
|
+
if (joined.orgName && joined.seatsUsed != null && joined.seatLimit != null) out(m.orgCreated(joined.orgName, joined.seatsUsed, joined.seatLimit));
|
|
228
|
+
if (!teamSyncDisabled()) {
|
|
229
|
+
const r = await syncTeam({ force: true, fetch, version });
|
|
230
|
+
if (r.error) console.error(m.syncFailed(r.error));
|
|
231
|
+
else if (!r.skipped) out(m.synced(r.uploaded, r.rules, r.prompts));
|
|
232
|
+
}
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function run({ args, hasFlag, getArg, version, fetch, interactive, ask }) {
|
|
237
|
+
const m = T[userLanguage()] || T.en;
|
|
238
|
+
const verb = args[1];
|
|
239
|
+
const out = (s) => console.log(s);
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
switch (verb) {
|
|
243
|
+
case 'create': {
|
|
244
|
+
const tty = interactive !== undefined ? interactive : !!(process.stdin.isTTY && process.stdout.isTTY);
|
|
245
|
+
return await createFlow({ m, out, hasFlag, getArg, version, fetch, interactive: tty, ask: ask || prompt });
|
|
246
|
+
}
|
|
247
|
+
case 'join': {
|
|
248
|
+
const token = (args[2] && !args[2].startsWith('--') ? args[2] : null) || process.env.SPRAG_TEAM_TOKEN;
|
|
249
|
+
if (!token) { console.error(m.noToken); return process.exit(2); }
|
|
250
|
+
let name = getArg('--name');
|
|
251
|
+
if (!name) { try { name = userInfo().username; } catch { name = 'member'; } }
|
|
252
|
+
out(m.willSend);
|
|
253
|
+
out(m.asName(name));
|
|
254
|
+
if (!hasFlag('--yes')) {
|
|
255
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) { console.error(m.needYes); return process.exit(2); }
|
|
256
|
+
if (!(await confirm(m.ask))) { out(m.aborted); return undefined; }
|
|
257
|
+
}
|
|
258
|
+
const st = await joinTeam({ token, displayName: name, version });
|
|
259
|
+
out(m.joined(st.teamName));
|
|
260
|
+
if (st.orgName && st.seatsUsed != null && st.seatLimit != null) out(m.orgCreated(st.orgName, st.seatsUsed, st.seatLimit));
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
case 'leave':
|
|
264
|
+
await leaveTeam();
|
|
265
|
+
out(m.left);
|
|
266
|
+
return undefined;
|
|
267
|
+
case 'status': {
|
|
268
|
+
const st = loadTeamState();
|
|
269
|
+
if (!isJoined(st)) { out(m.notJoined); return undefined; }
|
|
270
|
+
out(`${m.team}: ${st.teamName}`);
|
|
271
|
+
if (st.orgName) out(`${m.org}: ${st.orgName}`);
|
|
272
|
+
out(`${m.plan}: ${st.plan}`);
|
|
273
|
+
out(`${m.capture}: ${st.promptCapture ? m.on : m.off}`);
|
|
274
|
+
if (st.promptCapture) {
|
|
275
|
+
out(st.lastPromptUploadAt ? m.lastPrompts(new Date(st.lastPromptUploadAt).toLocaleString(), st.lastPromptCount || 0) : m.noPrompts);
|
|
276
|
+
}
|
|
277
|
+
out(`${m.name}: ${st.displayName}`);
|
|
278
|
+
out(st.lastSyncAt ? m.lastSync(new Date(st.lastSyncAt).toLocaleString(), st.lastSyncRows || 0) : m.neverSynced);
|
|
279
|
+
if (st.lastError) out(`${m.lastError}: ${st.lastError}`);
|
|
280
|
+
if (hasFlag('--show-payload')) {
|
|
281
|
+
out(`device: ${JSON.stringify(st.deviceInfo || deviceInfo())}`);
|
|
282
|
+
out(JSON.stringify(await aggregateDaily(), null, 2));
|
|
283
|
+
if (st.promptCapture) {
|
|
284
|
+
const pend = await pendingPrompts();
|
|
285
|
+
out(m.pendingPrompts(pend.count));
|
|
286
|
+
if (pend.example) out(m.pendingExample(pend.example));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
case 'sync': {
|
|
292
|
+
const rulesOnly = hasFlag('--rules-only');
|
|
293
|
+
if (hasFlag('--background')) {
|
|
294
|
+
if (!hasFlag('--prompts-only') && !rulesOnly) await autoJoin({ version });
|
|
295
|
+
await syncTeam({ version, promptsOnly: hasFlag('--prompts-only'), rulesOnly });
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
// Run by hand, --rules-only always pulls (no hourly gate).
|
|
299
|
+
const r = await syncTeam({ force: hasFlag('--force') || rulesOnly, rulesOnly, version });
|
|
300
|
+
if (hasFlag('--quiet')) return undefined;
|
|
301
|
+
if (r.error) { console.error(r.error); return process.exit(1); }
|
|
302
|
+
if (r.skipped) out(r.skipped === 'disabled' ? m.disabled : m.skipped(r.skipped));
|
|
303
|
+
else if (r.rulesOnly) out(m.rulesPulled(r.rules));
|
|
304
|
+
else out(m.synced(r.uploaded, r.rules, r.prompts));
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
case 'rules': {
|
|
308
|
+
let cache = null;
|
|
309
|
+
try { cache = JSON.parse(readFileSync(teamRulesCachePath(), 'utf8')); } catch { /* none cached */ }
|
|
310
|
+
if (formatRuleLines(cache).length === 0) { out(m.noRules); return undefined; }
|
|
311
|
+
for (const line of formatRuleLines(cache)) out(line);
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
case 'propose': {
|
|
315
|
+
const text = args.slice(2).filter((a, i, arr) => !a.startsWith('--') && arr[i - 1] !== '--scope').join(' ').trim();
|
|
316
|
+
if (!text) { console.error(m.needText); return process.exit(2); }
|
|
317
|
+
const scope = getArg('--scope') || 'ratchet';
|
|
318
|
+
if (scope !== 'ratchet' && scope !== 'model') { console.error(m.badScope); return process.exit(2); }
|
|
319
|
+
const st = loadTeamState();
|
|
320
|
+
if (!isJoined(st)) { console.error(m.needJoin); return process.exit(2); }
|
|
321
|
+
if (teamSyncDisabled()) { out(m.disabled); return undefined; }
|
|
322
|
+
const id = await rpc('team_rule_propose', { member_key: st.memberKey, rule_text: text, scope });
|
|
323
|
+
out(m.proposed(id));
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
default:
|
|
327
|
+
out(m.usage);
|
|
328
|
+
return verb && verb !== 'help' ? process.exit(2) : undefined;
|
|
329
|
+
}
|
|
330
|
+
} catch (e) {
|
|
331
|
+
console.error(isSeatLimitError(e) ? m.seatLimit : e.message);
|
|
332
|
+
return process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
@@ -46,8 +46,8 @@ export async function run({ hasFlag, version, agent = 'claude' }) {
|
|
|
46
46
|
|
|
47
47
|
if (updateCheckDisabled()) {
|
|
48
48
|
console.log(lang === 'ko'
|
|
49
|
-
? `현재 버전 v${version} — 업데이트 확인이 환경 변수로 꺼져 있습니다 (
|
|
50
|
-
: `v${version} — update checks are disabled by env var (
|
|
49
|
+
? `현재 버전 v${version} — 업데이트 확인이 환경 변수로 꺼져 있습니다 (SPRAG_NO_UPDATE_CHECK / NO_UPDATE_NOTIFIER).`
|
|
50
|
+
: `v${version} — update checks are disabled by env var (SPRAG_NO_UPDATE_CHECK / NO_UPDATE_NOTIFIER).`);
|
|
51
51
|
return;
|
|
52
52
|
}
|
|
53
53
|
|
package/src/cost.js
CHANGED
|
@@ -249,8 +249,11 @@ export function modelRank(model) {
|
|
|
249
249
|
return rank ?? 1;
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
/**
|
|
253
|
-
|
|
252
|
+
/**
|
|
253
|
+
* Price rank each delegation tier targets: T2 → haiku, T1 → sonnet, T1H → opus.
|
|
254
|
+
* T1H is worth it only from a rank-3 (Fable/Mythos) session.
|
|
255
|
+
*/
|
|
256
|
+
export const TIER_TARGET_RANK = { T2: 0, T1: 1, T1H: 2 };
|
|
254
257
|
|
|
255
258
|
/**
|
|
256
259
|
* Which delegation tier a subagent run at this price rank represents.
|
|
@@ -260,6 +263,7 @@ export const TIER_TARGET_RANK = { T2: 0, T1: 1 };
|
|
|
260
263
|
export function tierForRank(rank) {
|
|
261
264
|
if (rank === 0) return 'T2';
|
|
262
265
|
if (rank === 1) return 'T1';
|
|
266
|
+
if (rank === 2) return 'T1H';
|
|
263
267
|
return null;
|
|
264
268
|
}
|
|
265
269
|
|
package/src/cowork-sync.js
CHANGED
|
@@ -33,6 +33,7 @@ import { writeStateFile } from './state-file.js';
|
|
|
33
33
|
import { readSettings, writeSettings } from './installer.js';
|
|
34
34
|
import { modelRatchetPathFor } from './model-rules.js';
|
|
35
35
|
import { presetRatchetPath } from './preset-ratchet.js';
|
|
36
|
+
import { teamRatchetPath } from './team-ratchet.js';
|
|
36
37
|
import { globalRatchetMdPath } from './harness.js';
|
|
37
38
|
import { debug } from './debug.js';
|
|
38
39
|
|
|
@@ -183,11 +184,11 @@ export const coworkCopyPath = (dir) => join(dir, COPY_REL);
|
|
|
183
184
|
|
|
184
185
|
// ---------- content ----------
|
|
185
186
|
|
|
186
|
-
/** @returns {string|null} null when none of the
|
|
187
|
+
/** @returns {string|null} null when none of the source files exists */
|
|
187
188
|
export function renderCoworkRatchet(lang = userLanguage()) {
|
|
188
189
|
const ko = lang === 'ko';
|
|
189
190
|
const parts = [];
|
|
190
|
-
for (const file of [modelRatchetPathFor('global'), presetRatchetPath(), globalRatchetMdPath()]) {
|
|
191
|
+
for (const file of [modelRatchetPathFor('global'), presetRatchetPath(), teamRatchetPath(), globalRatchetMdPath()]) {
|
|
191
192
|
let body;
|
|
192
193
|
try { body = readFileSync(file, 'utf8'); } catch { continue; }
|
|
193
194
|
const name = file.split(/[\\/]/).pop();
|
|
@@ -195,8 +196,8 @@ export function renderCoworkRatchet(lang = userLanguage()) {
|
|
|
195
196
|
}
|
|
196
197
|
if (parts.length === 0) return null;
|
|
197
198
|
const intro = ko
|
|
198
|
-
? '이 파일은 sprag가 Claude 앱 Cowork 탭용으로 생성한 래칫 사본입니다. Cowork는 ~/.claude 의 설정과 import를 읽지 않으므로, 원본 래칫
|
|
199
|
-
: 'This file is a copy of the ratchet rules that sprag generates for the Cowork tab of the Claude app. Cowork does not read the settings or imports under ~/.claude, so the bodies of the
|
|
199
|
+
? '이 파일은 sprag가 Claude 앱 Cowork 탭용으로 생성한 래칫 사본입니다. Cowork는 ~/.claude 의 설정과 import를 읽지 않으므로, 원본 래칫 파일들의 본문을 여기에 옮겨 둡니다. sprag가 원본이 바뀔 때마다 이 파일을 통째로 다시 쓰므로 직접 고치지 마십시오. Claude Code(Code 탭)는 settings.json 의 claudeMdExcludes 로 이 파일을 읽지 않습니다.'
|
|
200
|
+
: 'This file is a copy of the ratchet rules that sprag generates for the Cowork tab of the Claude app. Cowork does not read the settings or imports under ~/.claude, so the bodies of the source ratchet files are copied here. sprag rewrites this whole file whenever a source changes, so do not edit it by hand. Claude Code (the Code tab) skips this file through claudeMdExcludes in settings.json.';
|
|
200
201
|
return `${COWORK_MARKER}\n${intro}\n\n${parts.join('\n')}`;
|
|
201
202
|
}
|
|
202
203
|
|
package/src/debug.js
CHANGED
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
* a genuinely broken path (bad permissions on the state dir, a corrupt hook
|
|
8
8
|
* payload) is indistinguishable from "nothing to do".
|
|
9
9
|
*
|
|
10
|
-
* `
|
|
10
|
+
* `SPRAG_DEBUG=1` makes those swallowed failures visible on stderr — which the
|
|
11
11
|
* statusline contract discards and hooks surface in Claude Code's debug
|
|
12
12
|
* output — without changing behavior in any way.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
import { envVar } from './env.js';
|
|
16
|
+
const ENABLED = !!envVar('DEBUG');
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* @param {string} scope short label for where the failure happened
|
package/src/delegation-agents.js
CHANGED
|
@@ -12,74 +12,50 @@
|
|
|
12
12
|
* that marker are ever overwritten or deleted; a same-named user file is left.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, readdirSync } from 'node:fs';
|
|
16
16
|
import { join } from 'node:path';
|
|
17
17
|
import { claudeUserDir } from './paths.js';
|
|
18
18
|
import { loadConfig } from './config.js';
|
|
19
|
+
import { agentDefinitions } from './presets-source.js';
|
|
19
20
|
|
|
20
21
|
export const OWNER_MARK = 'sprag:delegation-agent';
|
|
21
22
|
const MARK_LINE = `# managed by sprag (${OWNER_MARK}); edits are overwritten`;
|
|
22
23
|
|
|
24
|
+
// The definitions come from presets-source.js: the centrally published bundle
|
|
25
|
+
// when it verifies, else presets/agents.json. Both have one shape:
|
|
26
|
+
// { name, model, effort, tools: [..], description: { ko, en }, body: { ko, en } }
|
|
27
|
+
//
|
|
23
28
|
// Models are aliases on purpose: behind a gateway they resolve through
|
|
24
|
-
// ANTHROPIC_DEFAULT_*_MODEL, and a full model id would bypass that mapping.
|
|
29
|
+
// ANTHROPIC_DEFAULT_*_MODEL, and a full model id would bypass that mapping. The
|
|
30
|
+
// exception is opus-worker, which names the full id: Claude Code resolves a bare
|
|
31
|
+
// alias to an older model than the one the tier means.
|
|
25
32
|
//
|
|
26
33
|
// Effort is a separate axis from the tier and is set by how the work gets
|
|
27
34
|
// verified, not by how hard it is. Where the check is command output or a file
|
|
28
35
|
// comparison (lookups, test runs, mechanical conversion) extra thinking buys
|
|
29
36
|
// nothing, so those agents run at low. The worker implements a design the
|
|
30
37
|
// caller already fixed, against tests, so it takes the default for well-specified
|
|
31
|
-
// work, medium. The main session's effort is never touched here, and
|
|
38
|
+
// work, medium; the opus worker takes the larger, harder share of that work at high. The main session's effort is never touched here, and
|
|
32
39
|
// CLAUDE_CODE_EFFORT_LEVEL must stay unset: it overrides every agent's own value.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
role: 'Your role: read-only exploration, lookups, status checks, and questions about pasted logs or text.',
|
|
40
|
-
description: 'Cheap read-only exploration, lookup, status checks and summaries for delegated tasks. It has no MCP tools.',
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
name: 'haiku-runner',
|
|
44
|
-
model: 'haiku',
|
|
45
|
-
effort: 'low',
|
|
46
|
-
tools: 'Bash, Read, Grep, Glob',
|
|
47
|
-
role: 'Your role: run the build, test, or git commands you are given and report the result.',
|
|
48
|
-
description: 'Cheap runner for build, test and git commands in delegated tasks. It has no MCP tools.',
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
name: 'haiku-translate',
|
|
52
|
-
model: 'haiku',
|
|
53
|
-
effort: 'low',
|
|
54
|
-
tools: 'Read, Grep, Glob, Edit, Write',
|
|
55
|
-
role: 'Your role: batch translation and mechanical text conversion of the files or text you are given.',
|
|
56
|
-
description: 'Cheap batch translation and structured text conversion for delegated tasks. It has no MCP tools.',
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
name: 'sonnet-worker',
|
|
60
|
-
model: 'sonnet',
|
|
61
|
-
effort: 'medium',
|
|
62
|
-
tools: 'Read, Grep, Glob, Bash, Edit, Write, NotebookEdit',
|
|
63
|
-
role: 'Your role: multi-step work and the implementation half of a design the caller already fixed.',
|
|
64
|
-
description: 'Mid-tier worker for multi-step tasks and implementation handed over with a fixed design. It has no MCP tools.',
|
|
65
|
-
},
|
|
66
|
-
];
|
|
40
|
+
//
|
|
41
|
+
// The files are rendered from the English copy. They are prompts the model
|
|
42
|
+
// reads, and the Korean copy is what the /ops console shows beside it.
|
|
43
|
+
export function definitions() {
|
|
44
|
+
return agentDefinitions();
|
|
45
|
+
}
|
|
67
46
|
|
|
68
47
|
function render(def) {
|
|
48
|
+
const body = String((def.body && def.body.en) || '').replace(/\s+$/, '');
|
|
69
49
|
return [
|
|
70
50
|
'---',
|
|
71
51
|
`name: ${def.name}`,
|
|
72
|
-
`description: ${def.description}`,
|
|
73
|
-
`tools: ${def.tools}`,
|
|
52
|
+
`description: ${(def.description && def.description.en) || ''}`,
|
|
53
|
+
`tools: ${(Array.isArray(def.tools) ? def.tools : [def.tools]).join(', ')}`,
|
|
74
54
|
`model: ${def.model}`,
|
|
75
55
|
`effort: ${def.effort}`,
|
|
76
56
|
MARK_LINE,
|
|
77
57
|
'---',
|
|
78
|
-
|
|
79
|
-
'Do only the delegated task, and stay within the caps (tool calls, output tokens) the caller wrote in the prompt.',
|
|
80
|
-
"Report concisely, in the language of the caller's prompt.",
|
|
81
|
-
'If you need an MCP tool or any tool not on your list, stop at once and report one line: "needs general-purpose: <tools needed>". The parent will delegate again.',
|
|
82
|
-
'Do not guess at anything you could not verify; say that you could not verify it.',
|
|
58
|
+
body,
|
|
83
59
|
'',
|
|
84
60
|
].join('\n');
|
|
85
61
|
}
|
|
@@ -98,13 +74,27 @@ function readOwned(path) {
|
|
|
98
74
|
try { return { text: readFileSync(path, 'utf8') }; } catch { return null; }
|
|
99
75
|
}
|
|
100
76
|
|
|
77
|
+
/** Names of the files in the agents dir that carry our marker. */
|
|
78
|
+
function ownedNames() {
|
|
79
|
+
let entries = [];
|
|
80
|
+
try { entries = readdirSync(agentsDir()); } catch { return []; }
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const e of entries) {
|
|
83
|
+
if (!e.endsWith('.md')) continue;
|
|
84
|
+
const f = readOwned(join(agentsDir(), e));
|
|
85
|
+
if (f && isOwned(f.text)) out.push(e.slice(0, -3));
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
101
90
|
export function removeDelegationAgents() {
|
|
102
91
|
const removed = [];
|
|
103
|
-
|
|
104
|
-
|
|
92
|
+
const names = new Set([...definitions().map((d) => d.name), ...ownedNames()]);
|
|
93
|
+
for (const name of names) {
|
|
94
|
+
const p = fileOf(name);
|
|
105
95
|
try {
|
|
106
96
|
const f = readOwned(p);
|
|
107
|
-
if (f && isOwned(f.text)) { unlinkSync(p); removed.push(
|
|
97
|
+
if (f && isOwned(f.text)) { unlinkSync(p); removed.push(name); }
|
|
108
98
|
} catch { /* one file failing does not stop the rest */ }
|
|
109
99
|
}
|
|
110
100
|
return { written: [], unchanged: [], skipped: [], removed };
|
|
@@ -116,7 +106,8 @@ export function syncDelegationAgents({ agent = 'claude' } = {}) {
|
|
|
116
106
|
try {
|
|
117
107
|
if (loadConfig().delegationAgents === false) return removeDelegationAgents();
|
|
118
108
|
} catch { /* an unreadable config means the default, on */ }
|
|
119
|
-
|
|
109
|
+
const defs = definitions();
|
|
110
|
+
for (const def of defs) {
|
|
120
111
|
try {
|
|
121
112
|
const p = fileOf(def.name);
|
|
122
113
|
const want = render(def);
|
|
@@ -131,13 +122,22 @@ export function syncDelegationAgents({ agent = 'claude' } = {}) {
|
|
|
131
122
|
res.skipped.push({ name: def.name, reason: `error: ${e.message}` });
|
|
132
123
|
}
|
|
133
124
|
}
|
|
125
|
+
// An agent the published presets dropped or renamed: its old file is ours
|
|
126
|
+
// (the marker says so) and nothing names it any more.
|
|
127
|
+
if (defs.length > 0) {
|
|
128
|
+
const keep = new Set(defs.map((d) => d.name));
|
|
129
|
+
for (const name of ownedNames()) {
|
|
130
|
+
if (keep.has(name)) continue;
|
|
131
|
+
try { unlinkSync(fileOf(name)); res.removed.push(name); } catch { /* left for the next sync */ }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
134
|
return res;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
export function delegationAgentStatus() {
|
|
138
138
|
let enabled = true;
|
|
139
139
|
try { enabled = loadConfig().delegationAgents !== false; } catch { /* default on */ }
|
|
140
|
-
const agents =
|
|
140
|
+
const agents = definitions().map((def) => {
|
|
141
141
|
const f = existsSync(fileOf(def.name)) ? readOwned(fileOf(def.name)) : null;
|
|
142
142
|
const state = !f ? 'absent' : isOwned(f.text) ? 'installed-by-sprag' : 'user-file';
|
|
143
143
|
return { name: def.name, state };
|
package/src/delegation-guard.js
CHANGED
|
@@ -123,6 +123,7 @@ export async function buildAppendix(payload, { cfg = loadConfig(), home = homedi
|
|
|
123
123
|
// because the Korean section below weighs the same answer.
|
|
124
124
|
const modelHint = String(payload?.tool_input?.model || payload?.tool_input?.subagent_type || '');
|
|
125
125
|
const isHaiku = /haiku/i.test(modelHint);
|
|
126
|
+
const isOpus = /opus/i.test(modelHint);
|
|
126
127
|
|
|
127
128
|
const bounds = boundsText();
|
|
128
129
|
if (bounds) {
|
|
@@ -132,8 +133,8 @@ export async function buildAppendix(payload, { cfg = loadConfig(), home = homedi
|
|
|
132
133
|
// own calibrated budget for the tier replaces them, so the subagent is told
|
|
133
134
|
// the same limit the main model was (see route-caps.js). A T1 rule sets no
|
|
134
135
|
// call count, and the default one stays in that case.
|
|
135
|
-
const fallback = isHaiku ? { calls: 8, out: 1500 } : { calls: 20, out: 8000 };
|
|
136
|
-
const matched = routeCaps?.[isHaiku ? 'T2' : 'T1'];
|
|
136
|
+
const fallback = isHaiku ? { calls: 8, out: 1500 } : isOpus ? { calls: 20, out: 30000 } : { calls: 20, out: 8000 };
|
|
137
|
+
const matched = routeCaps?.[isHaiku ? 'T2' : isOpus ? 'T1H' : 'T1'];
|
|
137
138
|
const calls = Number.isFinite(matched?.calls) && matched.calls > 0 ? matched.calls : fallback.calls;
|
|
138
139
|
const out = Number.isFinite(matched?.out) && matched.out > 0 ? matched.out : fallback.out;
|
|
139
140
|
const capLine = `Cap for this delegation: ${calls} tool calls, ${out.toLocaleString('en-US')} output tokens.`;
|
package/src/doc2md.cjs
CHANGED
|
@@ -28,6 +28,7 @@ const path = require('node:path');
|
|
|
28
28
|
const crypto = require('node:crypto');
|
|
29
29
|
const { spawn, spawnSync } = require('node:child_process');
|
|
30
30
|
const ledger = require('./doc2md-ledger.cjs');
|
|
31
|
+
const { envVar } = require('./env.cjs');
|
|
31
32
|
|
|
32
33
|
// Formats where the original is of no use to the model. Images are absent
|
|
33
34
|
// deliberately: markitdown returns nothing for them, and OCR misread resource
|
|
@@ -247,7 +248,7 @@ const IS_WINDOWS = process.platform === 'win32';
|
|
|
247
248
|
*/
|
|
248
249
|
function interpreterCandidates() {
|
|
249
250
|
const out = [];
|
|
250
|
-
if (
|
|
251
|
+
if (envVar('DOC2MD_PYTHON')) out.push({ bin: envVar('DOC2MD_PYTHON'), args: [] });
|
|
251
252
|
// The tool's own venv, created by `doc2md install-converter`. First because
|
|
252
253
|
// it is the only one this tool controls: telling people to `pip install`
|
|
253
254
|
// into the system interpreter is how a token-saving CLI ends up owning a
|
|
@@ -477,7 +478,7 @@ function convert(filePath, { converter = CONVERTER, python: pythonOverride = nul
|
|
|
477
478
|
// The .fig parser is an npm install of a few hundred KB, fast enough to
|
|
478
479
|
// wait for inline the first time a Figma file turns up.
|
|
479
480
|
if (!result.ok && result.reason === 'no-figparser'
|
|
480
|
-
&&
|
|
481
|
+
&& envVar('DOC2MD_NO_AUTOINSTALL') !== '1') {
|
|
481
482
|
fig2md.installFigParser(userDataDir());
|
|
482
483
|
result = spawnFigConvert(fig2md, filePath);
|
|
483
484
|
}
|
|
@@ -565,7 +566,7 @@ function convert(filePath, { converter = CONVERTER, python: pythonOverride = nul
|
|
|
565
566
|
* Returns true when a converter is ready to use right now.
|
|
566
567
|
*/
|
|
567
568
|
function ensureConverterInstalled({ waitMs = 15_000 } = {}) {
|
|
568
|
-
if (
|
|
569
|
+
if (envVar('DOC2MD_NO_AUTOINSTALL') === '1') return false;
|
|
569
570
|
if (findInterpreter()) return true;
|
|
570
571
|
|
|
571
572
|
const lock = path.join(userDataDir(), 'doc2md-install.lock');
|
package/src/env.cjs
ADDED