@shinaps/skills 0.36.1 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -35
- package/dist/bin/skills.js +4 -1
- package/dist/lib/config-validation.js +2 -1
- package/dist/lib/install.js +1 -0
- package/dist/lib/layout.js +12 -0
- package/dist/lib/linear-config.js +2 -2
- package/dist/lib/managed-ant.js +70 -0
- package/dist/lib/managed-config.js +6 -6
- package/dist/lib/managed-session.js +274 -0
- package/dist/lib/managed-setup.js +167 -0
- package/dist/lib/managed.js +249 -0
- package/package.json +1 -1
- package/plugins/shinaps-claude/.claude-plugin/plugin.json +1 -1
- package/plugins/shinaps-claude/agents/shinaps-architect.md +35 -55
- package/plugins/shinaps-claude/agents/shinaps-explorer.md +5 -5
- package/plugins/shinaps-claude/agents/shinaps-plan-reviewer.md +16 -5
- package/plugins/shinaps-claude/agents/shinaps-refine-agent.md +2 -2
- package/plugins/shinaps-claude/agents/shinaps-reviewer.md +43 -9
- package/plugins/shinaps-claude/agents/shinaps-simplifier.md +1 -1
- package/plugins/shinaps-claude/managed-agents/agents/shinaps-architect.md +35 -55
- package/plugins/shinaps-claude/managed-agents/agents/shinaps-dev-managed-cc.md +6 -6
- package/plugins/shinaps-claude/managed-agents/agents/shinaps-plan-reviewer.md +16 -5
- package/plugins/shinaps-claude/managed-agents/agents/shinaps-reviewer.md +43 -9
- package/plugins/shinaps-claude/managed-agents/skills/dev-managed-cc/SKILL.md +3 -2
- package/plugins/shinaps-claude/managed-agents/skills/dev-managed-cc/references/design.md +37 -0
- package/plugins/shinaps-claude/managed-agents/skills/dev-managed-cc/references/launch.md +4 -4
- package/plugins/shinaps-claude/managed-agents/skills/dev-managed-cc/references/staffing.md +15 -17
- package/plugins/shinaps-claude/managed-agents/skills/dev-managed-cc/references/workflow.md +43 -29
- package/plugins/shinaps-claude/references/dev/design.md +37 -0
- package/plugins/shinaps-claude/references/dev/launch.md +1 -1
- package/plugins/shinaps-claude/references/dev/staffing.md +19 -19
- package/plugins/shinaps-claude/references/dev/workflow.md +56 -45
- package/plugins/shinaps-claude/references/human-review.md +1 -1
- package/plugins/shinaps-claude/references/linear/configuration.md +2 -2
- package/plugins/shinaps-claude/references/linear/execution.md +1 -1
- package/plugins/shinaps-claude/references/linear/workflow.md +3 -3
- package/plugins/shinaps-claude/references/managed/configuration.md +55 -30
- package/plugins/shinaps-claude/references/managed/configuration.schema.json +27 -17
- package/plugins/shinaps-claude/references/managed/execute.md +38 -74
- package/plugins/shinaps-claude/references/managed/setup.md +27 -39
- package/plugins/shinaps-claude/skills/commit/SKILL.md +1 -1
- package/plugins/shinaps-claude/skills/dev-claude/SKILL.md +3 -3
- package/plugins/shinaps-claude/skills/managed-execute/SKILL.md +6 -6
- package/plugins/shinaps-claude/skills/managed-setup/SKILL.md +6 -7
- package/plugins/shinaps-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/shinaps-codex/agents/shinaps-reviewer.toml +1 -1
- package/plugins/shinaps-codex/managed-agents/agents/shinaps-architect.md +35 -55
- package/plugins/shinaps-codex/managed-agents/agents/shinaps-dev-managed-cc.md +6 -6
- package/plugins/shinaps-codex/managed-agents/agents/shinaps-plan-reviewer.md +16 -5
- package/plugins/shinaps-codex/managed-agents/agents/shinaps-reviewer.md +43 -9
- package/plugins/shinaps-codex/managed-agents/skills/dev-managed-cc/SKILL.md +3 -2
- package/plugins/shinaps-codex/managed-agents/skills/dev-managed-cc/references/design.md +37 -0
- package/plugins/shinaps-codex/managed-agents/skills/dev-managed-cc/references/launch.md +4 -4
- package/plugins/shinaps-codex/managed-agents/skills/dev-managed-cc/references/staffing.md +15 -17
- package/plugins/shinaps-codex/managed-agents/skills/dev-managed-cc/references/workflow.md +43 -29
- package/plugins/shinaps-codex/references/dev/design.md +37 -0
- package/plugins/shinaps-codex/references/dev/staffing.md +19 -19
- package/plugins/shinaps-codex/references/dev/workflow.md +56 -45
- package/plugins/shinaps-codex/references/human-review.md +1 -1
- package/plugins/shinaps-codex/references/linear/configuration.md +2 -2
- package/plugins/shinaps-codex/references/linear/execution.md +1 -1
- package/plugins/shinaps-codex/references/linear/workflow.md +3 -3
- package/plugins/shinaps-codex/references/managed/configuration.md +55 -30
- package/plugins/shinaps-codex/references/managed/configuration.schema.json +27 -17
- package/plugins/shinaps-codex/references/managed/execute.md +38 -74
- package/plugins/shinaps-codex/references/managed/setup.md +27 -39
- package/plugins/shinaps-codex/skills/managed-execute/SKILL.md +6 -6
- package/plugins/shinaps-codex/skills/managed-setup/SKILL.md +6 -7
- package/plugins/shinaps-claude/agents/shinaps-implementer.md +0 -34
- package/plugins/shinaps-claude/agents/shinaps-review-verifier.md +0 -30
- package/plugins/shinaps-claude/managed-agents/agents/shinaps-implementer.md +0 -37
- package/plugins/shinaps-claude/managed-agents/agents/shinaps-refine-agent.md +0 -88
- package/plugins/shinaps-claude/managed-agents/agents/shinaps-review-verifier.md +0 -38
- package/plugins/shinaps-claude/managed-agents/agents/shinaps-simplifier.md +0 -44
- package/plugins/shinaps-codex/managed-agents/agents/shinaps-implementer.md +0 -37
- package/plugins/shinaps-codex/managed-agents/agents/shinaps-refine-agent.md +0 -88
- package/plugins/shinaps-codex/managed-agents/agents/shinaps-review-verifier.md +0 -38
- package/plugins/shinaps-codex/managed-agents/agents/shinaps-simplifier.md +0 -44
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { readJson, readJsonIfExists, writeFileAtomic } from './fs.js';
|
|
5
|
+
import { withLock } from './lock.js';
|
|
6
|
+
import { filesBeta, managedBeta } from './managed-ant.js';
|
|
7
|
+
export const staffings = ['LITE', 'BASIC', 'ULTRA'];
|
|
8
|
+
export const permissions = ['commit', 'push', 'draft_pr'];
|
|
9
|
+
const leadKey = './agents/shinaps-dev-managed-cc.md';
|
|
10
|
+
const environmentKey = './environments/shinaps-dev.yaml';
|
|
11
|
+
const permissionLabels = {
|
|
12
|
+
commit: 'commit',
|
|
13
|
+
push: 'headへのpush',
|
|
14
|
+
draft_pr: 'draft PRの作成',
|
|
15
|
+
};
|
|
16
|
+
const sha256 = (text) => createHash('sha256').update(text).digest('hex');
|
|
17
|
+
/**
|
|
18
|
+
* `--transform id -r` の出力から ID を取り出す。ant は transform のパスが応答に無いと応答の全体を
|
|
19
|
+
* 出すので、ID の形でない出力を ID として記録しない。
|
|
20
|
+
*/
|
|
21
|
+
async function readId(output, command) {
|
|
22
|
+
const id = (await output).trim();
|
|
23
|
+
if (!/^[A-Za-z0-9_]+$/.test(id))
|
|
24
|
+
throw new Error(`${command} returned no ID.`);
|
|
25
|
+
return id;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 依頼を lead へ送るセッションを作る。同じ入力の依頼は同じセッションを返し、作り直さない。
|
|
29
|
+
* 作成の途中で止まった記録があれば、重複を避けるために作らずに止める。
|
|
30
|
+
* @param githubToken マウントするリポジトリの token。本文 (stdin) だけで渡す。
|
|
31
|
+
* @param claudeMdPath `request.claude_md` の元のファイル。upload に使う。
|
|
32
|
+
*/
|
|
33
|
+
export async function runSession(account, request, options) {
|
|
34
|
+
const { ant, log } = options;
|
|
35
|
+
return withLock(account.dir, async () => {
|
|
36
|
+
const { task, claude_md, ...rest } = request;
|
|
37
|
+
const recorded = {
|
|
38
|
+
...rest,
|
|
39
|
+
task_sha256: sha256(task),
|
|
40
|
+
claude_md_sha256: claude_md === null ? null : sha256(claude_md),
|
|
41
|
+
};
|
|
42
|
+
const requestId = sha256(JSON.stringify(recorded)).slice(0, 32);
|
|
43
|
+
const recordPath = join(account.dir, 'sessions', `${requestId}.json`);
|
|
44
|
+
const previous = await readJsonIfExists(recordPath);
|
|
45
|
+
if (previous?.session_id) {
|
|
46
|
+
log(`Session already exists for this request: ${previous.session_id}`);
|
|
47
|
+
return previous.session_id;
|
|
48
|
+
}
|
|
49
|
+
if (previous) {
|
|
50
|
+
throw new Error(`A previous run of this request stopped while creating a session. Check the sessions of account ${account.name} for metadata shinaps_request=${requestId}; delete ${recordPath} only after confirming none was created, then retry.`);
|
|
51
|
+
}
|
|
52
|
+
const lock = await readJsonIfExists(join(account.dir, 'definitions', 'claude-lock.json'));
|
|
53
|
+
const agent = lock?.resources?.[leadKey]?.id;
|
|
54
|
+
const environment = lock?.resources?.[environmentKey]?.id;
|
|
55
|
+
if (!agent || !environment) {
|
|
56
|
+
throw new Error(`Account ${account.name} has no applied lead and environment. Run managed setup --apply first.`);
|
|
57
|
+
}
|
|
58
|
+
await mkdir(join(account.dir, 'sessions'), { recursive: true });
|
|
59
|
+
const record = {
|
|
60
|
+
request_id: requestId,
|
|
61
|
+
session_id: null,
|
|
62
|
+
created_at: options.now().toISOString(),
|
|
63
|
+
request: recorded,
|
|
64
|
+
};
|
|
65
|
+
// 作成より前に記録する。作成後に記録できずに終わっても、次の実行は止まり、重複して作らない。
|
|
66
|
+
await writeFileAtomic(recordPath, JSON.stringify(record, null, 2) + '\n');
|
|
67
|
+
const resources = [
|
|
68
|
+
{
|
|
69
|
+
type: 'github_repository',
|
|
70
|
+
url: request.repository.url,
|
|
71
|
+
mount_path: request.repository.mount_path,
|
|
72
|
+
...(options.githubToken ? { authorization_token: options.githubToken } : {}),
|
|
73
|
+
checkout: { type: 'commit', sha: request.base_sha },
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
if (options.claudeMdPath) {
|
|
77
|
+
// upload は記録の後に行う。記録の前だと、途中で止まった実行の再実行のたびに Files が増える。
|
|
78
|
+
let fileId;
|
|
79
|
+
try {
|
|
80
|
+
fileId = await readId(ant({
|
|
81
|
+
// セッションの Files と同じ beta の Files API に上げる。パスは絶対パスにする (ant は `-` を
|
|
82
|
+
// stdin と解釈する)。
|
|
83
|
+
argv: [
|
|
84
|
+
'beta:files',
|
|
85
|
+
'upload',
|
|
86
|
+
'--file',
|
|
87
|
+
resolve(options.claudeMdPath),
|
|
88
|
+
...filesBeta,
|
|
89
|
+
'--transform',
|
|
90
|
+
'id',
|
|
91
|
+
'-r',
|
|
92
|
+
],
|
|
93
|
+
apiKey: account.apiKey,
|
|
94
|
+
}), 'ant files upload');
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
// セッションはまだ作っていないので、記録を残すと同じ依頼の再実行が不要に止まる。
|
|
98
|
+
await rm(recordPath, { force: true });
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
resources.push({ type: 'file', file_id: fileId, mount_path: '/CLAUDE.md' });
|
|
102
|
+
}
|
|
103
|
+
const body = {
|
|
104
|
+
agent,
|
|
105
|
+
environment_id: environment,
|
|
106
|
+
...(request.vault_id ? { vault_ids: [request.vault_id] } : {}),
|
|
107
|
+
title: request.title ?? request.head,
|
|
108
|
+
metadata: { shinaps_request: requestId, head: request.head, staffing: request.staffing },
|
|
109
|
+
budget: {
|
|
110
|
+
type: 'limit',
|
|
111
|
+
max_list_cost: { amount: request.budget_cents, currency: 'USD' },
|
|
112
|
+
},
|
|
113
|
+
resources,
|
|
114
|
+
initial_events: [
|
|
115
|
+
{ type: 'user.message', content: [{ type: 'text', text: initialMessage(request) }] },
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
const sessionId = await readId(ant({
|
|
119
|
+
argv: ['beta:sessions', 'create', ...managedBeta, '--transform', 'id', '-r'],
|
|
120
|
+
apiKey: account.apiKey,
|
|
121
|
+
body,
|
|
122
|
+
}), 'ant beta:sessions create');
|
|
123
|
+
await writeFileAtomic(recordPath, JSON.stringify({ ...record, session_id: sessionId }, null, 2) + '\n');
|
|
124
|
+
log(`Created session ${sessionId} for account ${account.name}.`);
|
|
125
|
+
return sessionId;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
// lead の契約 (managed-agents/skills/dev-managed-cc/SKILL.md の初回メッセージ) の必須入力を、
|
|
129
|
+
// すべて明示する。欠けると lead は質問で止まり、それまでの費用が無駄になる。
|
|
130
|
+
function initialMessage(request) {
|
|
131
|
+
const budget = Number(request.budget_cents) / 100;
|
|
132
|
+
return [
|
|
133
|
+
`リポジトリ: ${request.repository.url}(マウント先 ${request.repository.mount_path})`,
|
|
134
|
+
`base: ${request.base} @ ${request.base_sha}`,
|
|
135
|
+
`head: ${request.head}`,
|
|
136
|
+
`範囲と受入条件:\n${request.task.trimEnd()}`,
|
|
137
|
+
`体制: ${request.staffing}`,
|
|
138
|
+
`許可: ${request.allow.length ? request.allow.map((name) => permissionLabels[name]).join('・') : 'なし(commit・push・draft PRの作成はしない)'}`,
|
|
139
|
+
`予算: ${budget} USD(セッションの上限 ${request.budget_cents} セント)`,
|
|
140
|
+
`必須検証(任意): ${request.verify ?? '指定なし。リポジトリの規約に従う'}`,
|
|
141
|
+
`author(任意): ${request.author ?? '指定なし'}`,
|
|
142
|
+
...(request.claude_md === null ? [] : ['利用者 CLAUDE.md: /mnt/session/uploads/CLAUDE.md']),
|
|
143
|
+
].join('\n');
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* このアカウントの記録にあるセッションか確かめる。別アカウントのキーや、CLI が作っていない
|
|
147
|
+
* セッションを観測・操作しないため。
|
|
148
|
+
*/
|
|
149
|
+
async function requireRecordedSession(account, sessionId) {
|
|
150
|
+
const directory = join(account.dir, 'sessions');
|
|
151
|
+
const names = await readdir(directory).catch(() => []);
|
|
152
|
+
for (const name of names.filter((entry) => entry.endsWith('.json'))) {
|
|
153
|
+
const record = await readJson(join(directory, name));
|
|
154
|
+
if (record.session_id === sessionId)
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
throw new Error(`Session ${sessionId} was not created by managed run for account ${account.name}.`);
|
|
158
|
+
}
|
|
159
|
+
/** セッションの状態と、lead の最後のメッセージ (1 行目が `状態: 完了|質問|未完了`) を返す。 */
|
|
160
|
+
export async function sessionStatus(account, sessionId, ant) {
|
|
161
|
+
await requireRecordedSession(account, sessionId);
|
|
162
|
+
const call = (argv) => ant({ argv, apiKey: account.apiKey });
|
|
163
|
+
const session = JSON.parse(await call([
|
|
164
|
+
'beta:sessions',
|
|
165
|
+
'retrieve',
|
|
166
|
+
'--session-id',
|
|
167
|
+
sessionId,
|
|
168
|
+
...managedBeta,
|
|
169
|
+
'--format',
|
|
170
|
+
'json',
|
|
171
|
+
'--transform',
|
|
172
|
+
'{status,usage,budget}',
|
|
173
|
+
]));
|
|
174
|
+
// 最新の agent.message 1 件だけを取る。全イベント (ツールの呼び出しを含む) を取得しないため。
|
|
175
|
+
const [last] = (await call([
|
|
176
|
+
'beta:sessions:events',
|
|
177
|
+
'list',
|
|
178
|
+
'--session-id',
|
|
179
|
+
sessionId,
|
|
180
|
+
...managedBeta,
|
|
181
|
+
'--type',
|
|
182
|
+
'agent.message',
|
|
183
|
+
'--order',
|
|
184
|
+
'desc',
|
|
185
|
+
'--max-items',
|
|
186
|
+
'1',
|
|
187
|
+
'--format',
|
|
188
|
+
'jsonl',
|
|
189
|
+
'--transform',
|
|
190
|
+
'{content}',
|
|
191
|
+
]))
|
|
192
|
+
.split('\n')
|
|
193
|
+
.filter((line) => line.trim())
|
|
194
|
+
.map((line) => JSON.parse(line));
|
|
195
|
+
const message = last?.content
|
|
196
|
+
?.filter((block) => block.type === 'text')
|
|
197
|
+
.map((block) => block.text)
|
|
198
|
+
.join('\n') ?? null;
|
|
199
|
+
const state = message?.match(/^状態: (完了|質問|未完了)/)?.[1] ?? null;
|
|
200
|
+
return { session_id: sessionId, ...session, state, message };
|
|
201
|
+
}
|
|
202
|
+
/** 質問への回答を user.message として送る。新しい許可・予算はここでは加えられない (本文だけ)。 */
|
|
203
|
+
export async function replySession(account, sessionId, text, ant) {
|
|
204
|
+
await requireRecordedSession(account, sessionId);
|
|
205
|
+
await ant({
|
|
206
|
+
argv: ['beta:sessions:events', 'send', '--session-id', sessionId, ...managedBeta],
|
|
207
|
+
apiKey: account.apiKey,
|
|
208
|
+
body: { events: [{ type: 'user.message', content: [{ type: 'text', text }] }] },
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* セッションの出力ファイルを `sessions/<session_id>/outputs/` へ保存し、保存したパスを返す。
|
|
213
|
+
* 差分の適用はしない。同名のファイルは最新のものだけを保存する (lead は毎ターン changes.patch を
|
|
214
|
+
* 全体の差分で置き換えるため)。
|
|
215
|
+
*/
|
|
216
|
+
export async function collectOutputs(account, sessionId, ant) {
|
|
217
|
+
await requireRecordedSession(account, sessionId);
|
|
218
|
+
const listed = (await ant({
|
|
219
|
+
argv: [
|
|
220
|
+
'beta:files',
|
|
221
|
+
'list',
|
|
222
|
+
'--scope-id',
|
|
223
|
+
sessionId,
|
|
224
|
+
...filesBeta,
|
|
225
|
+
'--format',
|
|
226
|
+
'jsonl',
|
|
227
|
+
'--transform',
|
|
228
|
+
'{id,filename,size_bytes,created_at}',
|
|
229
|
+
],
|
|
230
|
+
apiKey: account.apiKey,
|
|
231
|
+
}))
|
|
232
|
+
.split('\n')
|
|
233
|
+
.filter((line) => line.trim())
|
|
234
|
+
.map((line) => JSON.parse(line));
|
|
235
|
+
const latest = new Map();
|
|
236
|
+
for (const file of listed) {
|
|
237
|
+
// 名前はクラウドのエージェントが決める。保存先の外へ書かないよう、ファイル名だけのものに限る。
|
|
238
|
+
// ID は ant の引数に渡すので、フラグとして解釈される形 (`-` で始まる) を受け付けない。
|
|
239
|
+
if (!/^[^/\\\0]+$/.test(file.filename) ||
|
|
240
|
+
file.filename === '.' ||
|
|
241
|
+
file.filename === '..' ||
|
|
242
|
+
!/^[A-Za-z0-9_]+$/.test(file.id)) {
|
|
243
|
+
throw new Error(`Refusing to save an output with an unsafe file name (file ${file.id}).`);
|
|
244
|
+
}
|
|
245
|
+
const current = latest.get(file.filename);
|
|
246
|
+
// 時刻は小数秒の桁数が揃うとは限らないので、文字列ではなく時刻として比べる。
|
|
247
|
+
if (!current || Date.parse(current.created_at) < Date.parse(file.created_at)) {
|
|
248
|
+
latest.set(file.filename, file);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const directory = join(account.dir, 'sessions', sessionId, 'outputs');
|
|
252
|
+
await mkdir(directory, { recursive: true });
|
|
253
|
+
const saved = [];
|
|
254
|
+
for (const file of latest.values()) {
|
|
255
|
+
const path = join(directory, file.filename);
|
|
256
|
+
const temporary = join(directory, `.${file.filename}.${randomUUID()}.tmp`);
|
|
257
|
+
try {
|
|
258
|
+
await ant({
|
|
259
|
+
argv: ['beta:files', 'download', '--file-id', file.id, ...filesBeta, '--output', temporary],
|
|
260
|
+
apiKey: account.apiKey,
|
|
261
|
+
});
|
|
262
|
+
const { size } = await stat(temporary);
|
|
263
|
+
if (file.size_bytes !== undefined && size !== file.size_bytes) {
|
|
264
|
+
throw new Error(`Downloaded ${file.filename} has ${size} bytes, but the Files API lists ${file.size_bytes}.`);
|
|
265
|
+
}
|
|
266
|
+
await rename(temporary, path);
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
await rm(temporary, { force: true });
|
|
270
|
+
}
|
|
271
|
+
saved.push(path);
|
|
272
|
+
}
|
|
273
|
+
return saved;
|
|
274
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readdir, readFile, rm } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
4
|
+
import { errorCode, readJsonIfExists, readTextIfExists, writeFileAtomic } from './fs.js';
|
|
5
|
+
import { withLock } from './lock.js';
|
|
6
|
+
import { managedTemplatePath } from './layout.js';
|
|
7
|
+
import { managedBeta } from './managed-ant.js';
|
|
8
|
+
/** 共通定義の 3 ディレクトリ。`definitions/` には、これと `claude-lock.json` 以外を置かない。 */
|
|
9
|
+
const definitionDirs = ['agents', 'environments', 'skills'];
|
|
10
|
+
const lockName = 'claude-lock.json';
|
|
11
|
+
const upToDate = /Everything is up to date\./;
|
|
12
|
+
/**
|
|
13
|
+
* 1 アカウントへ共通定義を導入・更新する。`apply` が false なら dry-run の計画を表示するだけで、
|
|
14
|
+
* クラウドを変更しない。`--prune`・`--force` は使わない (リソースを削除・上書きしないため)。
|
|
15
|
+
* @param source 同梱の共通定義を含むパッケージのルート。
|
|
16
|
+
* @returns 計画だけを示した (`planned`)、適用した (`applied`)、変更が無かった (`up-to-date`)。
|
|
17
|
+
*/
|
|
18
|
+
export async function setupAccount(account, options) {
|
|
19
|
+
const { ant, log } = options;
|
|
20
|
+
const definitions = join(account.dir, 'definitions');
|
|
21
|
+
// ant は --lock-file が無いと lock を cwd から上へ探す。definitions に lock が無いとき祖先の lock を
|
|
22
|
+
// 拾うと、別の組織や構成の lock で対応づけてしまうので、definitions 直下の lock を常に指定する
|
|
23
|
+
// (指定した lock が無ければ ant はそこへ新しく作る。キーは lock のディレクトリ基準で変わらない)。
|
|
24
|
+
const run = (mode) => ant({
|
|
25
|
+
argv: ['apply', mode, '--lock-file', lockName, '.'],
|
|
26
|
+
apiKey: account.apiKey,
|
|
27
|
+
cwd: definitions,
|
|
28
|
+
});
|
|
29
|
+
return withLock(account.dir, async () => {
|
|
30
|
+
await syncDefinitions(definitions, join(options.source, managedTemplatePath), join(account.dir, 'definitions.json'));
|
|
31
|
+
if ((await readTextIfExists(join(definitions, lockName))) === null) {
|
|
32
|
+
await refuseDuplicates(account, definitions, ant);
|
|
33
|
+
}
|
|
34
|
+
const plan = await run('--dry-run');
|
|
35
|
+
log(plan.trimEnd());
|
|
36
|
+
// dry-run は適用不能な計画でも終了コード 0 で返るので、計画の文言で判定する。
|
|
37
|
+
if (/This plan cannot be applied/.test(plan)) {
|
|
38
|
+
throw new Error(`The plan for account ${account.name} cannot be applied. See the plan above.`);
|
|
39
|
+
}
|
|
40
|
+
if (upToDate.test(plan))
|
|
41
|
+
return 'up-to-date';
|
|
42
|
+
if (!options.apply)
|
|
43
|
+
return 'planned';
|
|
44
|
+
log((await run('--yes')).trimEnd());
|
|
45
|
+
if (!upToDate.test(await run('--dry-run'))) {
|
|
46
|
+
throw new Error(`Account ${account.name} still has pending changes after apply. Run setup again without --apply to see them.`);
|
|
47
|
+
}
|
|
48
|
+
return 'applied';
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* definitions の3ディレクトリを同梱の定義に揃える。書き換え・削除するのは、setup が最後に書いた
|
|
53
|
+
* 内容 (`definitions.json` の記録) か今回書く内容と一致するファイルだけ。利用者の編集・追加や、
|
|
54
|
+
* 記録の無い既存ファイル (旧版の定義ごと複写した場合など) があれば、何も変えずに止める。apply は
|
|
55
|
+
* ディレクトリ内の定義をすべて送るので、利用者の変更を黙って送ることも失うこともしないため。
|
|
56
|
+
* definitions には同梱の定義だけを置く。利用者の変更は取り込まず、止めて退避してもらう。
|
|
57
|
+
* 途中で止まった同期は、各ファイルが記録か今回のどちらかに一致するので、再実行で続きから揃う。
|
|
58
|
+
* @param record 最後に書いたファイルの相対パス → sha256。apply が送らないよう definitions の外に置く。
|
|
59
|
+
*/
|
|
60
|
+
async function syncDefinitions(definitions, template, record) {
|
|
61
|
+
await mkdir(definitions, { recursive: true });
|
|
62
|
+
const unexpected = (await readdir(definitions)).filter((name) => name !== lockName && !definitionDirs.includes(name));
|
|
63
|
+
const current = await hashTree(definitions, unexpected);
|
|
64
|
+
const next = await hashTree(template, []);
|
|
65
|
+
const written = (await readJsonIfExists(record)) ?? {};
|
|
66
|
+
// 無いファイルは変更にしない。同梱の定義を書き直すだけで利用者の内容は失われず、停止して退避を
|
|
67
|
+
// 求めると、退避した後の再実行も同じ理由で止まり続けるため。
|
|
68
|
+
const changed = Object.keys(current)
|
|
69
|
+
.filter((path) => current[path] !== written[path] && current[path] !== next[path])
|
|
70
|
+
.sort();
|
|
71
|
+
if (changed.length) {
|
|
72
|
+
throw new Error(`Files in ${definitions} differ from what setup wrote: ${changed.join(', ')}. Setup does not overwrite, delete or apply them; it applies only the bundled definitions. Move them out of ${definitions} (keep ${lockName}), then retry and review the plan: it reverts cloud resources that were changed to match the bundled definitions.`);
|
|
73
|
+
}
|
|
74
|
+
for (const [path, hash] of Object.entries(next)) {
|
|
75
|
+
if (current[path] === hash)
|
|
76
|
+
continue;
|
|
77
|
+
await mkdir(dirname(join(definitions, path)), { recursive: true });
|
|
78
|
+
await writeFileAtomic(join(definitions, path), await readFile(join(template, path), 'utf8'));
|
|
79
|
+
}
|
|
80
|
+
for (const path of Object.keys(current).filter((path) => !(path in next))) {
|
|
81
|
+
await rm(join(definitions, path));
|
|
82
|
+
}
|
|
83
|
+
await removeEmptyDirectories(definitions);
|
|
84
|
+
await writeFileAtomic(record, JSON.stringify(next, null, 2) + '\n');
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 3ディレクトリ配下の通常ファイルの相対パス → sha256。通常ファイル以外 (シンボリックリンクなど) と
|
|
88
|
+
* `extra` の項目は、内容を比べられない変更として値を空にする (記録・同梱のどちらとも一致しない)。
|
|
89
|
+
*/
|
|
90
|
+
async function hashTree(root, extra) {
|
|
91
|
+
const result = Object.fromEntries(extra.map((name) => [name, '']));
|
|
92
|
+
for (const directory of definitionDirs) {
|
|
93
|
+
const kind = await lstat(join(root, directory)).catch((error) => {
|
|
94
|
+
if (errorCode(error) === 'ENOENT')
|
|
95
|
+
return null;
|
|
96
|
+
throw error;
|
|
97
|
+
});
|
|
98
|
+
if (!kind)
|
|
99
|
+
continue;
|
|
100
|
+
// 3ディレクトリ自体がシンボリックリンクなどなら、辿るとリンク先へ書き込むので変更として止める。
|
|
101
|
+
if (!kind.isDirectory()) {
|
|
102
|
+
result[directory] = '';
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const entries = await readdir(join(root, directory), { recursive: true, withFileTypes: true });
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
if (entry.isDirectory())
|
|
108
|
+
continue;
|
|
109
|
+
const path = relative(root, join(entry.parentPath, entry.name)).split(sep).join('/');
|
|
110
|
+
result[path] = entry.isFile()
|
|
111
|
+
? createHash('sha256')
|
|
112
|
+
.update(await readFile(join(root, path)))
|
|
113
|
+
.digest('hex')
|
|
114
|
+
: '';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
// 定義を消した後に残る空のディレクトリを消す。中身の無い skill のディレクトリを apply に渡さないため。
|
|
120
|
+
async function removeEmptyDirectories(definitions) {
|
|
121
|
+
for (const directory of definitionDirs) {
|
|
122
|
+
const root = join(definitions, directory);
|
|
123
|
+
const nested = (await readdir(root, { recursive: true, withFileTypes: true }))
|
|
124
|
+
.filter((entry) => entry.isDirectory())
|
|
125
|
+
.map((entry) => join(entry.parentPath, entry.name))
|
|
126
|
+
.sort((a, b) => b.length - a.length);
|
|
127
|
+
for (const path of nested) {
|
|
128
|
+
if ((await readdir(path)).length === 0)
|
|
129
|
+
await rm(path, { recursive: true });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// lock が無いと apply は既存のリソースと対応づかず、同じ名前で新しく作る。lock の hash は既存の
|
|
134
|
+
// リソースから作り直せないので、同名のリソースがあれば作らずに止め、lock を戻してもらう。
|
|
135
|
+
async function refuseDuplicates(account, definitions, ant) {
|
|
136
|
+
const names = new Set();
|
|
137
|
+
for (const directory of ['agents', 'environments']) {
|
|
138
|
+
for (const file of await readdir(join(definitions, directory))) {
|
|
139
|
+
const name = (await readFile(join(definitions, directory, file), 'utf8')).match(/^name: (.+)$/m)?.[1];
|
|
140
|
+
if (name)
|
|
141
|
+
names.add(name.trim());
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const existing = [];
|
|
145
|
+
for (const resource of ['beta:agents', 'beta:environments']) {
|
|
146
|
+
const output = await ant({
|
|
147
|
+
argv: [
|
|
148
|
+
resource,
|
|
149
|
+
'list',
|
|
150
|
+
...managedBeta,
|
|
151
|
+
'--format',
|
|
152
|
+
'jsonl',
|
|
153
|
+
'--transform',
|
|
154
|
+
'{name,archived_at}',
|
|
155
|
+
],
|
|
156
|
+
apiKey: account.apiKey,
|
|
157
|
+
});
|
|
158
|
+
for (const line of output.split('\n').filter((text) => text.trim())) {
|
|
159
|
+
const item = JSON.parse(line);
|
|
160
|
+
if (item.name && names.has(item.name) && !item.archived_at)
|
|
161
|
+
existing.push(item.name);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (existing.length) {
|
|
165
|
+
throw new Error(`Account ${account.name} has no lock, but resources with the same names exist: ${[...new Set(existing)].join(', ')}. Applying would create duplicates. If you used Managed Agents before, move the previous claude-lock.json (for example <definitions>/claude-lock.json from .shinaps/managed.json version 1) to ${definitions}/, then retry.`);
|
|
166
|
+
}
|
|
167
|
+
}
|