axstack 0.9.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/LICENSE +21 -0
- package/README.md +132 -0
- package/bin/axstack.js +396 -0
- package/docs/installation.md +239 -0
- package/docs/workflows.md +220 -0
- package/package.json +40 -0
- package/profiles/presets/claude-only.json +194 -0
- package/profiles/presets/codex-only.json +194 -0
- package/profiles/presets/mixed.json +194 -0
- package/skills/axstack/SKILL.md +81 -0
- package/skills/axstack/references/automations.md +368 -0
- package/skills/axstack/references/candidate-publication.md +45 -0
- package/skills/axstack/references/contracts.md +102 -0
- package/skills/axstack/references/lifecycle.md +137 -0
- package/skills/axstack/references/orca-runtime.md +109 -0
- package/skills/axstack/references/pr-shape.md +39 -0
- package/skills/axstack/references/routing.md +129 -0
- package/skills/axstack/references/run-record.md +109 -0
- package/skills/axstack-align/SKILL.md +121 -0
- package/skills/axstack-audit/SKILL.md +137 -0
- package/skills/axstack-audit/references/record.md +28 -0
- package/skills/axstack-debug/SKILL.md +157 -0
- package/skills/axstack-debug/references/packet.md +80 -0
- package/skills/axstack-explain/SKILL.md +66 -0
- package/skills/axstack-explain/references/visual-qa.md +15 -0
- package/skills/axstack-implement/SKILL.md +164 -0
- package/skills/axstack-improve/SKILL.md +69 -0
- package/skills/axstack-relay/SKILL.md +102 -0
- package/skills/axstack-research/SKILL.md +57 -0
- package/skills/axstack-research/references/checklist.md +25 -0
- package/skills/axstack-review/SKILL.md +343 -0
- package/skills/axstack-spec/SKILL.md +67 -0
- package/skills/axstack-tickets/SKILL.md +86 -0
- package/skills/axstack-watch/SKILL.md +160 -0
- package/skills/axstack-watch/references/repair-publication.md +69 -0
- package/skills/axstack-watch/references/watch-runtime.md +60 -0
- package/src/capabilities.js +138 -0
- package/src/claude-settings.js +230 -0
- package/src/installer.js +980 -0
- package/src/instructions.js +100 -0
- package/src/locations.js +43 -0
- package/src/manifest.js +251 -0
- package/src/posixpath.js +108 -0
- package/src/roles.js +142 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, join, resolve } from './posixpath.js';
|
|
3
|
+
import { hashContent } from './manifest.js';
|
|
4
|
+
|
|
5
|
+
export const CLAUDE_SETTING_KEY = 'CLAUDE_CODE_SUBAGENT_MODEL';
|
|
6
|
+
export const CLAUDE_SETTING_PATH = `env.${CLAUDE_SETTING_KEY}`;
|
|
7
|
+
export const CLAUDE_SETTING_VALUE = 'opus';
|
|
8
|
+
export const CLAUDE_SIDECAR_NAME = '.axstack-settings.json';
|
|
9
|
+
|
|
10
|
+
async function canonicalParent(dir) {
|
|
11
|
+
let cur = resolve(dir);
|
|
12
|
+
const tail = [];
|
|
13
|
+
for (;;) {
|
|
14
|
+
try {
|
|
15
|
+
return join(await realpath(cur), ...tail);
|
|
16
|
+
} catch (err) {
|
|
17
|
+
if (err?.code !== 'ENOENT') throw err;
|
|
18
|
+
const parent = dirname(cur);
|
|
19
|
+
if (parent === cur) throw new Error(`cannot resolve settings directory: ${dir}`);
|
|
20
|
+
tail.unshift(basename(cur));
|
|
21
|
+
cur = parent;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function safeFile(path, label) {
|
|
27
|
+
const file = join(await canonicalParent(dirname(resolve(path))), basename(path));
|
|
28
|
+
const fileStat = await lstat(file).catch((err) => {
|
|
29
|
+
if (err?.code === 'ENOENT') return null;
|
|
30
|
+
throw err;
|
|
31
|
+
});
|
|
32
|
+
if (fileStat?.isSymbolicLink()) {
|
|
33
|
+
throw new Error(`unsafe Claude ${label}: path is a symlink at ${file}; refusing`);
|
|
34
|
+
}
|
|
35
|
+
return file;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readJson(path, label) {
|
|
39
|
+
let raw = null;
|
|
40
|
+
try {
|
|
41
|
+
raw = await readFile(path, 'utf8');
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err?.code !== 'ENOENT') throw err;
|
|
44
|
+
}
|
|
45
|
+
if (raw === null) return { raw, value: null };
|
|
46
|
+
try {
|
|
47
|
+
const value = JSON.parse(raw);
|
|
48
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error();
|
|
49
|
+
return { raw, value };
|
|
50
|
+
} catch {
|
|
51
|
+
throw new Error(`malformed Claude ${label} at ${path}; refusing to mutate it`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validateSidecar(sidecar, path, settingsPath) {
|
|
56
|
+
if (sidecar === null) return { version: 1, settingsPath, keys: {} };
|
|
57
|
+
if (
|
|
58
|
+
sidecar.version !== 1 || typeof sidecar.settingsPath !== 'string' ||
|
|
59
|
+
typeof sidecar.keys !== 'object' || sidecar.keys === null || Array.isArray(sidecar.keys)
|
|
60
|
+
) {
|
|
61
|
+
throw new Error(`malformed Claude settings ownership sidecar at ${path}; refusing to mutate it`);
|
|
62
|
+
}
|
|
63
|
+
if (sidecar.settingsPath !== settingsPath) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Claude settings ownership is bound to ${sidecar.settingsPath}; refusing target ${settingsPath}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
for (const entry of Object.values(sidecar.keys)) {
|
|
69
|
+
if (
|
|
70
|
+
typeof entry !== 'object' || entry === null || Array.isArray(entry) ||
|
|
71
|
+
typeof entry.hash !== 'string' || !Array.isArray(entry.owners) ||
|
|
72
|
+
entry.owners.some((owner) => typeof owner !== 'string')
|
|
73
|
+
) {
|
|
74
|
+
throw new Error(`malformed Claude settings ownership sidecar at ${path}; refusing to mutate it`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return sidecar;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const jsonBytes = (value) => JSON.stringify(value, null, 2) + '\n';
|
|
81
|
+
|
|
82
|
+
export async function planInstallClaudeSettings({ claude, skillsRoot }) {
|
|
83
|
+
if (!claude?.available) {
|
|
84
|
+
return {
|
|
85
|
+
report: { status: 'skipped', reason: claude?.reason ?? 'Claude Code is not available' },
|
|
86
|
+
writes: [],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (!claude.settingsPath) {
|
|
90
|
+
return { report: { status: 'skipped', reason: 'Claude settings path is unavailable' }, writes: [] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const settingsPath = await safeFile(claude.settingsPath, 'settings');
|
|
94
|
+
const sidecarPath = await safeFile(join(dirname(settingsPath), CLAUDE_SIDECAR_NAME), 'settings ownership sidecar');
|
|
95
|
+
const settingsFile = await readJson(settingsPath, 'settings JSON');
|
|
96
|
+
const sidecarFile = await readJson(sidecarPath, 'settings ownership sidecar');
|
|
97
|
+
const sidecar = validateSidecar(sidecarFile.value, sidecarPath, settingsPath);
|
|
98
|
+
const settings = settingsFile.value ?? {};
|
|
99
|
+
if (
|
|
100
|
+
settings.env !== undefined &&
|
|
101
|
+
(typeof settings.env !== 'object' || settings.env === null || Array.isArray(settings.env))
|
|
102
|
+
) {
|
|
103
|
+
throw new Error(`malformed Claude settings JSON at ${settingsPath}; env must be an object`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const hasValue = Object.hasOwn(settings.env ?? {}, CLAUDE_SETTING_KEY);
|
|
107
|
+
const currentValue = settings.env?.[CLAUDE_SETTING_KEY];
|
|
108
|
+
const owned = sidecar.keys[CLAUDE_SETTING_PATH];
|
|
109
|
+
const currentHash = hasValue ? hashContent(currentValue) : null;
|
|
110
|
+
|
|
111
|
+
if (hasValue && (!owned || owned.hash !== currentHash)) {
|
|
112
|
+
return {
|
|
113
|
+
report: { status: 'preserved', value: currentValue, path: settingsPath },
|
|
114
|
+
writes: [],
|
|
115
|
+
settingsPath,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const owners = [...new Set([...(owned?.owners ?? []), skillsRoot])].sort();
|
|
120
|
+
const nextSidecar = {
|
|
121
|
+
...sidecar,
|
|
122
|
+
keys: {
|
|
123
|
+
...sidecar.keys,
|
|
124
|
+
[CLAUDE_SETTING_PATH]: { hash: hashContent(CLAUDE_SETTING_VALUE), owners },
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
const writes = [];
|
|
128
|
+
if (!hasValue) {
|
|
129
|
+
const nextSettings = {
|
|
130
|
+
...settings,
|
|
131
|
+
env: { ...(settings.env ?? {}), [CLAUDE_SETTING_KEY]: CLAUDE_SETTING_VALUE },
|
|
132
|
+
};
|
|
133
|
+
writes.push({ path: settingsPath, before: settingsFile.raw, after: jsonBytes(nextSettings) });
|
|
134
|
+
}
|
|
135
|
+
const nextSidecarRaw = jsonBytes(nextSidecar);
|
|
136
|
+
if (nextSidecarRaw !== sidecarFile.raw) {
|
|
137
|
+
writes.push({ path: sidecarPath, before: sidecarFile.raw, after: nextSidecarRaw });
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
report: hasValue
|
|
141
|
+
? { status: 'unchanged', path: settingsPath }
|
|
142
|
+
: { status: 'set', path: settingsPath },
|
|
143
|
+
writes,
|
|
144
|
+
settingsPath,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function planUninstallClaudeSettings({ claude, skillsRoot, boundPath = null }) {
|
|
149
|
+
if (claude?.skip) {
|
|
150
|
+
return {
|
|
151
|
+
report: { status: 'skipped', reason: claude.reason ?? 'disabled by --no-claude-settings' },
|
|
152
|
+
writes: [],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (!boundPath && !claude?.available) {
|
|
156
|
+
return {
|
|
157
|
+
report: { status: 'skipped', reason: claude?.reason ?? 'Claude Code is not available' },
|
|
158
|
+
writes: [],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const requestedPath = boundPath && !claude?.explicit
|
|
162
|
+
? boundPath
|
|
163
|
+
: (claude?.settingsPath ?? boundPath);
|
|
164
|
+
if (!requestedPath) {
|
|
165
|
+
return { report: { status: 'skipped', reason: 'Claude settings path is unavailable' }, writes: [] };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const settingsPath = await safeFile(requestedPath, 'settings');
|
|
169
|
+
if (boundPath && settingsPath !== boundPath) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`Claude settings ownership is bound to ${boundPath}; refusing target ${settingsPath}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
const sidecarPath = await safeFile(join(dirname(settingsPath), CLAUDE_SIDECAR_NAME), 'settings ownership sidecar');
|
|
175
|
+
const settingsFile = await readJson(settingsPath, 'settings JSON');
|
|
176
|
+
const sidecarFile = await readJson(sidecarPath, 'settings ownership sidecar');
|
|
177
|
+
const sidecar = validateSidecar(sidecarFile.value, sidecarPath, settingsPath);
|
|
178
|
+
const settings = settingsFile.value ?? {};
|
|
179
|
+
if (
|
|
180
|
+
settings.env !== undefined &&
|
|
181
|
+
(typeof settings.env !== 'object' || settings.env === null || Array.isArray(settings.env))
|
|
182
|
+
) {
|
|
183
|
+
throw new Error(`malformed Claude settings JSON at ${settingsPath}; env must be an object`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const owned = sidecar.keys[CLAUDE_SETTING_PATH];
|
|
187
|
+
if (!owned || !owned.owners.includes(skillsRoot)) {
|
|
188
|
+
return {
|
|
189
|
+
report: { status: 'skipped', reason: 'this skills directory does not own the Claude setting' },
|
|
190
|
+
writes: [],
|
|
191
|
+
settingsPath,
|
|
192
|
+
unbind: Boolean(boundPath),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const remainingOwners = owned.owners.filter((owner) => owner !== skillsRoot);
|
|
197
|
+
const nextKeys = { ...sidecar.keys };
|
|
198
|
+
let report;
|
|
199
|
+
const writes = [];
|
|
200
|
+
if (remainingOwners.length > 0) {
|
|
201
|
+
nextKeys[CLAUDE_SETTING_PATH] = { ...owned, owners: remainingOwners };
|
|
202
|
+
report = { status: 'retained', path: settingsPath, owners: remainingOwners };
|
|
203
|
+
} else {
|
|
204
|
+
delete nextKeys[CLAUDE_SETTING_PATH];
|
|
205
|
+
const hasValue = Object.hasOwn(settings.env ?? {}, CLAUDE_SETTING_KEY);
|
|
206
|
+
const currentValue = settings.env?.[CLAUDE_SETTING_KEY];
|
|
207
|
+
if (hasValue && hashContent(currentValue) === owned.hash) {
|
|
208
|
+
const nextEnv = { ...settings.env };
|
|
209
|
+
delete nextEnv[CLAUDE_SETTING_KEY];
|
|
210
|
+
writes.push({
|
|
211
|
+
path: settingsPath,
|
|
212
|
+
before: settingsFile.raw,
|
|
213
|
+
after: jsonBytes({ ...settings, env: nextEnv }),
|
|
214
|
+
});
|
|
215
|
+
report = { status: 'removed', path: settingsPath };
|
|
216
|
+
} else if (hasValue) {
|
|
217
|
+
report = { status: 'preserved', value: currentValue, path: settingsPath };
|
|
218
|
+
} else {
|
|
219
|
+
report = { status: 'released', path: settingsPath };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const nextSidecar = { ...sidecar, keys: nextKeys };
|
|
224
|
+
writes.push({
|
|
225
|
+
path: sidecarPath,
|
|
226
|
+
before: sidecarFile.raw,
|
|
227
|
+
after: Object.keys(nextKeys).length === 0 ? null : jsonBytes(nextSidecar),
|
|
228
|
+
});
|
|
229
|
+
return { report, writes, settingsPath, unbind: Boolean(boundPath) };
|
|
230
|
+
}
|