@dashclaw/cli 0.2.0 → 0.3.1
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 +104 -0
- package/bin/dashclaw.js +903 -44
- package/lib/api.js +64 -0
- package/lib/code/apply.js +164 -0
- package/lib/code/codex-parser.vendored.js +360 -0
- package/lib/code/ingest-codex.js +244 -0
- package/lib/code/ingest.js +245 -0
- package/lib/code/memo.js +57 -0
- package/lib/code/vendored.js +219 -0
- package/lib/codex/install.js +405 -0
- package/lib/codex/notify.js +203 -0
- package/lib/config.js +160 -0
- package/lib/doctor.js +209 -0
- package/lib/posture.js +45 -0
- package/package.json +21 -18
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// VENDORED from app/lib/claude-code/optimal-files/merge.js and
|
|
2
|
+
// app/lib/claude-code/optimal-files/bundle.js.
|
|
3
|
+
//
|
|
4
|
+
// The CLI is a sibling package and can't import from app/lib/ without
|
|
5
|
+
// monorepo tooling, but Phase 6's `dashclaw code apply` needs the
|
|
6
|
+
// path-traversal guard and the markdown merge applier locally. Keep this
|
|
7
|
+
// file in sync with the canonical source via
|
|
8
|
+
// `node scripts/sync-cli-vendored-code.mjs`.
|
|
9
|
+
//
|
|
10
|
+
// Source of truth:
|
|
11
|
+
// app/lib/claude-code/optimal-files/merge.js
|
|
12
|
+
// app/lib/claude-code/optimal-files/bundle.js#absolutize
|
|
13
|
+
//
|
|
14
|
+
// Only `applyMerge`, `previewMerge`, the heading/bullet helpers, and the
|
|
15
|
+
// `_ensureInsideProject` guard are vendored. Everything else stays on the
|
|
16
|
+
// server side.
|
|
17
|
+
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
// --- _ensureInsideProject (originally from bundle.js#absolutize) ---------
|
|
21
|
+
|
|
22
|
+
export function _ensureInsideProject(projectCwd, candidatePath) {
|
|
23
|
+
if (!projectCwd || !candidatePath) return null;
|
|
24
|
+
let abs;
|
|
25
|
+
if (path.isAbsolute(candidatePath)) {
|
|
26
|
+
abs = path.normalize(candidatePath);
|
|
27
|
+
} else {
|
|
28
|
+
abs = path.normalize(path.join(projectCwd, candidatePath));
|
|
29
|
+
}
|
|
30
|
+
const cwdNorm = path.normalize(projectCwd);
|
|
31
|
+
const ci = process.platform === 'win32';
|
|
32
|
+
const a = ci ? abs.toLowerCase() : abs;
|
|
33
|
+
const b = ci ? cwdNorm.toLowerCase() : cwdNorm;
|
|
34
|
+
if (a !== b && !a.startsWith(b + path.sep)) return null;
|
|
35
|
+
return abs;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// --- markdown merge (originally from merge.js) ---------------------------
|
|
39
|
+
|
|
40
|
+
export function parseMarkdownSections(text) {
|
|
41
|
+
const lines = String(text || '').split(/\r?\n/);
|
|
42
|
+
const sections = [];
|
|
43
|
+
let cur = { level: 0, heading: '__preamble__', headingRaw: '', body: [] };
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
const m = line.match(/^(#{1,6})\s+(.+?)\s*$/);
|
|
46
|
+
if (m) {
|
|
47
|
+
sections.push(cur);
|
|
48
|
+
cur = { level: m[1].length, heading: normalizeHeading(m[2]), headingRaw: m[2], body: [] };
|
|
49
|
+
} else {
|
|
50
|
+
cur.body.push(line);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
sections.push(cur);
|
|
54
|
+
return sections;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function normalizeHeading(h) {
|
|
58
|
+
return String(h || '')
|
|
59
|
+
.toLowerCase()
|
|
60
|
+
.replace(/[`*_]/g, '')
|
|
61
|
+
.replace(/[^\w\s]+/g, ' ')
|
|
62
|
+
.replace(/\s+/g, ' ')
|
|
63
|
+
.trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseBullets(bodyLines) {
|
|
67
|
+
const bullets = [];
|
|
68
|
+
const other = [];
|
|
69
|
+
for (const line of bodyLines) {
|
|
70
|
+
if (/^\s*[-*]\s+/.test(line)) bullets.push({ raw: line, normalized: normalizeBullet(line) });
|
|
71
|
+
else other.push(line);
|
|
72
|
+
}
|
|
73
|
+
return { bullets, other };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeBullet(line) {
|
|
77
|
+
return String(line || '')
|
|
78
|
+
.replace(/^\s*[-*]\s+/, '')
|
|
79
|
+
.replace(/`[^`]*`/g, ' ')
|
|
80
|
+
.replace(/[^a-z0-9\s]+/gi, ' ')
|
|
81
|
+
.toLowerCase()
|
|
82
|
+
.trim()
|
|
83
|
+
.split(/\s+/)
|
|
84
|
+
.slice(0, 8)
|
|
85
|
+
.join(' ');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function bulletAlreadyPresent(generatedNorm, existingBullets) {
|
|
89
|
+
if (!generatedNorm) return true;
|
|
90
|
+
for (const b of existingBullets) {
|
|
91
|
+
if (!b.normalized) continue;
|
|
92
|
+
if (b.normalized.startsWith(generatedNorm)) return true;
|
|
93
|
+
if (generatedNorm.startsWith(b.normalized) && b.normalized.length >= 4) return true;
|
|
94
|
+
const aWords = generatedNorm.split(' ');
|
|
95
|
+
const bWords = b.normalized.split(' ');
|
|
96
|
+
let shared = 0;
|
|
97
|
+
for (let i = 0; i < Math.min(aWords.length, bWords.length); i++) {
|
|
98
|
+
if (aWords[i] === bWords[i]) shared++; else break;
|
|
99
|
+
}
|
|
100
|
+
if (shared >= 5) return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isFooterSection(section) {
|
|
106
|
+
if (!section || !Array.isArray(section.body)) return false;
|
|
107
|
+
const joined = section.body.join('\n').toLowerCase();
|
|
108
|
+
return (joined.includes('generated by dashclaw') || joined.includes('generated by agentlens')) && joined.includes('review before committing');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function renderSection(section) {
|
|
112
|
+
return `## ${section.headingRaw}\n${section.body.join('\n')}`.replace(/\n+$/, '\n');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function previewMerge(existing, generated) {
|
|
116
|
+
const ex = parseMarkdownSections(existing);
|
|
117
|
+
const gn = parseMarkdownSections(generated);
|
|
118
|
+
const exByHeading = new Map();
|
|
119
|
+
for (const s of ex) {
|
|
120
|
+
if (s.heading === '__preamble__') continue;
|
|
121
|
+
if (!exByHeading.has(s.heading)) exByHeading.set(s.heading, s);
|
|
122
|
+
}
|
|
123
|
+
const appendSections = [];
|
|
124
|
+
const sharedSections = [];
|
|
125
|
+
for (let i = 0; i < gn.length; i++) {
|
|
126
|
+
const s = gn[i];
|
|
127
|
+
if (s.heading === '__preamble__') continue;
|
|
128
|
+
if (isFooterSection(s)) continue;
|
|
129
|
+
if (s.level !== 2) continue;
|
|
130
|
+
const exSection = exByHeading.get(s.heading);
|
|
131
|
+
if (!exSection) {
|
|
132
|
+
appendSections.push({ heading: s.heading, headingRaw: s.headingRaw, bodyText: renderSection(s) });
|
|
133
|
+
} else {
|
|
134
|
+
const exBullets = parseBullets(exSection.body).bullets;
|
|
135
|
+
const genBullets = parseBullets(s.body).bullets;
|
|
136
|
+
const candidates = [];
|
|
137
|
+
for (const gb of genBullets) {
|
|
138
|
+
if (gb.normalized && !bulletAlreadyPresent(gb.normalized, exBullets)) {
|
|
139
|
+
candidates.push({ text: gb.raw.replace(/^\s*[-*]\s+/, '- ') });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (candidates.length) {
|
|
143
|
+
sharedSections.push({
|
|
144
|
+
heading: s.heading,
|
|
145
|
+
headingRaw: s.headingRaw,
|
|
146
|
+
existingBulletCount: exBullets.length,
|
|
147
|
+
candidateBullets: candidates,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
appendSections,
|
|
154
|
+
sharedSections,
|
|
155
|
+
newSectionCount: appendSections.length,
|
|
156
|
+
sharedSectionCount: sharedSections.length,
|
|
157
|
+
candidateBulletCount: sharedSections.reduce((acc, s) => acc + s.candidateBullets.length, 0),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function trimTrailingBlanks(lines) {
|
|
162
|
+
const out = lines.slice();
|
|
163
|
+
while (out.length && out[out.length - 1].trim() === '') out.pop();
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function applyMerge(existing, generated, selection) {
|
|
168
|
+
const preview = previewMerge(existing, generated);
|
|
169
|
+
selection = selection || {};
|
|
170
|
+
const acceptedHeadings = new Set((selection.acceptedHeadings || []).map(h => normalizeHeading(h)));
|
|
171
|
+
const acceptedBulletsByHeading = new Map();
|
|
172
|
+
for (const b of (selection.acceptedBullets || [])) {
|
|
173
|
+
const k = normalizeHeading(b.heading);
|
|
174
|
+
if (!acceptedBulletsByHeading.has(k)) acceptedBulletsByHeading.set(k, []);
|
|
175
|
+
acceptedBulletsByHeading.get(k).push(b.text);
|
|
176
|
+
}
|
|
177
|
+
let merged = String(existing == null ? '' : existing);
|
|
178
|
+
if (merged && !merged.endsWith('\n')) merged += '\n';
|
|
179
|
+
if (acceptedBulletsByHeading.size) {
|
|
180
|
+
const sections = parseMarkdownSections(merged);
|
|
181
|
+
const rebuilt = [];
|
|
182
|
+
for (const s of sections) {
|
|
183
|
+
if (s.heading === '__preamble__') { rebuilt.push(s.body.join('\n')); continue; }
|
|
184
|
+
const headingLine = `${'#'.repeat(s.level)} ${s.headingRaw}`;
|
|
185
|
+
const accepted = acceptedBulletsByHeading.get(s.heading) || [];
|
|
186
|
+
if (accepted.length) {
|
|
187
|
+
const trimmedBody = trimTrailingBlanks(s.body);
|
|
188
|
+
rebuilt.push(headingLine);
|
|
189
|
+
rebuilt.push(trimmedBody.join('\n'));
|
|
190
|
+
rebuilt.push('');
|
|
191
|
+
rebuilt.push('<!-- dashclaw:merged-bullets -->');
|
|
192
|
+
for (const t of accepted) rebuilt.push(t.startsWith('- ') ? t : `- ${t}`);
|
|
193
|
+
rebuilt.push('<!-- /dashclaw:merged-bullets -->');
|
|
194
|
+
rebuilt.push('');
|
|
195
|
+
} else {
|
|
196
|
+
rebuilt.push(headingLine);
|
|
197
|
+
rebuilt.push(s.body.join('\n'));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
merged = rebuilt.join('\n').replace(/\n+$/, '\n');
|
|
201
|
+
}
|
|
202
|
+
const wholeAdditions = preview.appendSections.filter(s => acceptedHeadings.has(s.heading));
|
|
203
|
+
if (wholeAdditions.length) {
|
|
204
|
+
if (!merged.endsWith('\n')) merged += '\n';
|
|
205
|
+
merged += '\n<!-- dashclaw:merged-sections -->\n\n';
|
|
206
|
+
for (const s of wholeAdditions) {
|
|
207
|
+
merged += s.bodyText.endsWith('\n') ? s.bodyText : s.bodyText + '\n';
|
|
208
|
+
merged += '\n';
|
|
209
|
+
}
|
|
210
|
+
merged += '<!-- /dashclaw:merged-sections -->\n';
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
merged,
|
|
214
|
+
additions: {
|
|
215
|
+
sections: wholeAdditions.map(s => s.headingRaw),
|
|
216
|
+
bulletCount: [...acceptedBulletsByHeading.values()].reduce((acc, l) => acc + l.length, 0),
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
// cli/lib/codex/install.js
|
|
2
|
+
//
|
|
3
|
+
// `dashclaw install codex` — provisions DashClaw governance into Codex CLI.
|
|
4
|
+
//
|
|
5
|
+
// What it does, idempotently:
|
|
6
|
+
// 1. Copies the Python governance hooks (pretool, posttool, stop) and the
|
|
7
|
+
// vendored `dashclaw_agent_intel` module into ~/.codex/hooks/dashclaw/.
|
|
8
|
+
// 2. Merges a managed block into ~/.codex/config.toml that registers:
|
|
9
|
+
// - the DashClaw MCP server (stdio)
|
|
10
|
+
// - PreToolUse / PostToolUse / Stop hooks pointing at the copied scripts
|
|
11
|
+
// - approval_policy = "on-request" so Codex surfaces require_approval
|
|
12
|
+
// decisions from DashClaw guard
|
|
13
|
+
// 3. Drops a managed block into <project>/AGENTS.md (or creates the file)
|
|
14
|
+
// that teaches the Codex agent the DashClaw governance protocol.
|
|
15
|
+
//
|
|
16
|
+
// Idempotency is implemented with sentinel markers:
|
|
17
|
+
//
|
|
18
|
+
// # >>> dashclaw start — managed block, do not edit by hand
|
|
19
|
+
// ...
|
|
20
|
+
// # <<< dashclaw end
|
|
21
|
+
//
|
|
22
|
+
// Re-running replaces only the block between the markers. Anything else in
|
|
23
|
+
// the file is left untouched.
|
|
24
|
+
//
|
|
25
|
+
// Safety: every file we mutate gets a `.dashclaw-bak` sibling on first write
|
|
26
|
+
// so the user always has an escape hatch.
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
existsSync,
|
|
30
|
+
readFileSync,
|
|
31
|
+
writeFileSync,
|
|
32
|
+
mkdirSync,
|
|
33
|
+
copyFileSync,
|
|
34
|
+
readdirSync,
|
|
35
|
+
statSync,
|
|
36
|
+
} from 'node:fs';
|
|
37
|
+
import { dirname, join, resolve } from 'node:path';
|
|
38
|
+
import { homedir } from 'node:os';
|
|
39
|
+
|
|
40
|
+
// -----------------------------------------------------------------------------
|
|
41
|
+
// Constants
|
|
42
|
+
// -----------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
export const CODEX_HOME_DIRNAME = '.codex';
|
|
45
|
+
export const DASHCLAW_HOOKS_SUBDIR = 'hooks/dashclaw';
|
|
46
|
+
|
|
47
|
+
export const MANAGED_START = '# >>> dashclaw start — managed block, do not edit by hand';
|
|
48
|
+
export const MANAGED_END = '# <<< dashclaw end';
|
|
49
|
+
|
|
50
|
+
export const AGENTS_MANAGED_START =
|
|
51
|
+
'<!-- >>> dashclaw start — managed block, do not edit by hand -->';
|
|
52
|
+
export const AGENTS_MANAGED_END = '<!-- <<< dashclaw end -->';
|
|
53
|
+
|
|
54
|
+
// Hook filenames we ship from `hooks/`. The agent_intel module is a directory
|
|
55
|
+
// and is handled separately.
|
|
56
|
+
const HOOK_FILES = [
|
|
57
|
+
'dashclaw_pretool.py',
|
|
58
|
+
'dashclaw_posttool.py',
|
|
59
|
+
'dashclaw_stop.py',
|
|
60
|
+
];
|
|
61
|
+
const HOOK_INTEL_DIR = 'dashclaw_agent_intel';
|
|
62
|
+
|
|
63
|
+
// -----------------------------------------------------------------------------
|
|
64
|
+
// Path resolution
|
|
65
|
+
// -----------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export function codexHome(env = process.env) {
|
|
68
|
+
if (env.CODEX_HOME) return resolve(env.CODEX_HOME);
|
|
69
|
+
return resolve(homedir(), CODEX_HOME_DIRNAME);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function codexConfigPath(env = process.env) {
|
|
73
|
+
return join(codexHome(env), 'config.toml');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function codexHooksDir(env = process.env) {
|
|
77
|
+
return join(codexHome(env), DASHCLAW_HOOKS_SUBDIR);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// -----------------------------------------------------------------------------
|
|
81
|
+
// Hook copy
|
|
82
|
+
// -----------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
function copyDirRecursive(src, dst) {
|
|
85
|
+
mkdirSync(dst, { recursive: true });
|
|
86
|
+
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
87
|
+
const sp = join(src, entry.name);
|
|
88
|
+
const dp = join(dst, entry.name);
|
|
89
|
+
if (entry.isDirectory()) {
|
|
90
|
+
if (entry.name === '__pycache__') continue;
|
|
91
|
+
copyDirRecursive(sp, dp);
|
|
92
|
+
} else if (entry.isFile()) {
|
|
93
|
+
copyFileSync(sp, dp);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function copyHooks({ hooksSrc, hooksDst }) {
|
|
99
|
+
if (!existsSync(hooksSrc)) {
|
|
100
|
+
throw new Error(`Hook source directory not found: ${hooksSrc}`);
|
|
101
|
+
}
|
|
102
|
+
mkdirSync(hooksDst, { recursive: true });
|
|
103
|
+
|
|
104
|
+
for (const name of HOOK_FILES) {
|
|
105
|
+
const sp = join(hooksSrc, name);
|
|
106
|
+
if (!existsSync(sp)) {
|
|
107
|
+
throw new Error(`Required hook script missing: ${sp}`);
|
|
108
|
+
}
|
|
109
|
+
copyFileSync(sp, join(hooksDst, name));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const intelSrc = join(hooksSrc, HOOK_INTEL_DIR);
|
|
113
|
+
if (existsSync(intelSrc) && statSync(intelSrc).isDirectory()) {
|
|
114
|
+
copyDirRecursive(intelSrc, join(hooksDst, HOOK_INTEL_DIR));
|
|
115
|
+
} else {
|
|
116
|
+
throw new Error(`Required intel module missing: ${intelSrc}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { hooksDst, files: HOOK_FILES, intelDir: HOOK_INTEL_DIR };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// -----------------------------------------------------------------------------
|
|
123
|
+
// TOML managed-block merge
|
|
124
|
+
// -----------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
// We do NOT parse TOML. Instead we maintain a single contiguous block
|
|
127
|
+
// delimited by MANAGED_START / MANAGED_END comment markers and rewrite that
|
|
128
|
+
// block on every install. This avoids depending on a third-party TOML
|
|
129
|
+
// library and keeps user-authored content outside the block 100% intact.
|
|
130
|
+
|
|
131
|
+
export function replaceManagedBlock(
|
|
132
|
+
source,
|
|
133
|
+
newBlock,
|
|
134
|
+
{ startMarker = MANAGED_START, endMarker = MANAGED_END } = {},
|
|
135
|
+
) {
|
|
136
|
+
const start = source.indexOf(startMarker);
|
|
137
|
+
const end = source.indexOf(endMarker);
|
|
138
|
+
|
|
139
|
+
if (start !== -1 && end !== -1 && end > start) {
|
|
140
|
+
const before = source.slice(0, start);
|
|
141
|
+
const after = source.slice(end + endMarker.length);
|
|
142
|
+
return ensureTrailingNewline(
|
|
143
|
+
stripTrailingBlankLines(before) +
|
|
144
|
+
(before ? '\n\n' : '') +
|
|
145
|
+
newBlock +
|
|
146
|
+
(after.startsWith('\n') ? '' : '\n') +
|
|
147
|
+
after,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// No existing block — append.
|
|
152
|
+
const sep = source.length === 0 || source.endsWith('\n') ? '\n' : '\n\n';
|
|
153
|
+
return ensureTrailingNewline(
|
|
154
|
+
source + (source.length === 0 ? '' : sep) + newBlock,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function stripTrailingBlankLines(s) {
|
|
159
|
+
return s.replace(/\n+$/, '');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function ensureTrailingNewline(s) {
|
|
163
|
+
return s.endsWith('\n') ? s : s + '\n';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// -----------------------------------------------------------------------------
|
|
167
|
+
// Block builders
|
|
168
|
+
// -----------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
// Quote a path for inclusion in a TOML basic string. TOML basic strings
|
|
171
|
+
// require `\` and `"` to be escaped.
|
|
172
|
+
function tomlString(value) {
|
|
173
|
+
return '"' + String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function buildConfigTomlBlock({
|
|
177
|
+
mcpServerPath,
|
|
178
|
+
hooksDir,
|
|
179
|
+
approvalPolicy = 'on-request',
|
|
180
|
+
includeNotify = false,
|
|
181
|
+
dashclawCliPath = null,
|
|
182
|
+
}) {
|
|
183
|
+
const py = pythonCommand();
|
|
184
|
+
const pre = join(hooksDir, 'dashclaw_pretool.py');
|
|
185
|
+
const post = join(hooksDir, 'dashclaw_posttool.py');
|
|
186
|
+
const stop = join(hooksDir, 'dashclaw_stop.py');
|
|
187
|
+
|
|
188
|
+
const lines = [
|
|
189
|
+
MANAGED_START,
|
|
190
|
+
'#',
|
|
191
|
+
'# Re-run `dashclaw install codex` to refresh this block. Edits made',
|
|
192
|
+
'# between these markers will be overwritten on next install.',
|
|
193
|
+
'',
|
|
194
|
+
`approval_policy = ${tomlString(approvalPolicy)}`,
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
if (includeNotify) {
|
|
198
|
+
if (!dashclawCliPath) {
|
|
199
|
+
throw new Error('dashclawCliPath is required when includeNotify=true');
|
|
200
|
+
}
|
|
201
|
+
// Codex's notify config takes a list of strings: argv prefix. Codex
|
|
202
|
+
// appends the JSON payload as the final argument when it fires.
|
|
203
|
+
lines.push(
|
|
204
|
+
`notify = ["node", ${tomlString(dashclawCliPath)}, "codex", "notify"]`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
lines.push(
|
|
209
|
+
'',
|
|
210
|
+
'[mcp_servers.dashclaw]',
|
|
211
|
+
`command = ${tomlString(py)}`,
|
|
212
|
+
`args = [${tomlString(mcpServerPath)}, "--agent-id", "codex"]`,
|
|
213
|
+
'',
|
|
214
|
+
'[[hooks.PreToolUse]]',
|
|
215
|
+
'matcher = "Bash|Edit|Write|MultiEdit"',
|
|
216
|
+
'[[hooks.PreToolUse.hooks]]',
|
|
217
|
+
'type = "command"',
|
|
218
|
+
`command = ${tomlString(`${py} ${pre}`)}`,
|
|
219
|
+
'timeoutSec = 3600',
|
|
220
|
+
'',
|
|
221
|
+
'[[hooks.PostToolUse]]',
|
|
222
|
+
'matcher = "Bash|Edit|Write|MultiEdit"',
|
|
223
|
+
'[[hooks.PostToolUse.hooks]]',
|
|
224
|
+
'type = "command"',
|
|
225
|
+
`command = ${tomlString(`${py} ${post}`)}`,
|
|
226
|
+
'',
|
|
227
|
+
'[[hooks.Stop]]',
|
|
228
|
+
'[[hooks.Stop.hooks]]',
|
|
229
|
+
'type = "command"',
|
|
230
|
+
`command = ${tomlString(`${py} ${stop}`)}`,
|
|
231
|
+
MANAGED_END,
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
return lines.join('\n');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function pythonCommand() {
|
|
238
|
+
// Use `python` on Windows, `python3` elsewhere. The hook scripts work with
|
|
239
|
+
// either; we just pick the canonical name for the OS so the spawn succeeds
|
|
240
|
+
// without PATH gymnastics. Users can override by editing config.toml after
|
|
241
|
+
// install (the managed block warns about overwrites — but the python
|
|
242
|
+
// command is the only path-dependent piece).
|
|
243
|
+
return process.platform === 'win32' ? 'python' : 'python3';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// -----------------------------------------------------------------------------
|
|
247
|
+
// AGENTS.md template
|
|
248
|
+
// -----------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
export function buildAgentsMdBlock({ baseUrl } = {}) {
|
|
251
|
+
const lines = [
|
|
252
|
+
AGENTS_MANAGED_START,
|
|
253
|
+
'',
|
|
254
|
+
'## DashClaw Governance Protocol',
|
|
255
|
+
'',
|
|
256
|
+
'You are governed by DashClaw. Before any non-trivial action, follow this',
|
|
257
|
+
'protocol so a human reviewer (and the audit log) can trust your work.',
|
|
258
|
+
'',
|
|
259
|
+
'### Session start',
|
|
260
|
+
'',
|
|
261
|
+
'1. Call `dashclaw_session_start` via the `dashclaw` MCP server with your',
|
|
262
|
+
' agent id (`codex`) and a one-sentence workspace description. This',
|
|
263
|
+
' groups all your actions for tracking in Mission Control.',
|
|
264
|
+
'2. Read the `dashclaw://policies` and `dashclaw://capabilities` MCP',
|
|
265
|
+
' resources to learn what rules govern you and what capabilities are',
|
|
266
|
+
' registered. Treat unknown action types as high-risk by default.',
|
|
267
|
+
'',
|
|
268
|
+
'### Before each risky action',
|
|
269
|
+
'',
|
|
270
|
+
"Call `dashclaw_guard` with the action you intend to take. You will get",
|
|
271
|
+
'back one of four decisions:',
|
|
272
|
+
'',
|
|
273
|
+
'- `allow` — proceed; call `dashclaw_record` afterward with the outcome.',
|
|
274
|
+
'- `warn` — proceed with caution; include the warning context in your',
|
|
275
|
+
' `dashclaw_record` call.',
|
|
276
|
+
'- `block` — stop. Report the block reason to the user and do not attempt',
|
|
277
|
+
' the action through another path.',
|
|
278
|
+
"- `require_approval` — call `dashclaw_wait_for_approval` and wait. Don't",
|
|
279
|
+
' poll faster than the tool already does.',
|
|
280
|
+
'',
|
|
281
|
+
'Risky actions include: shell commands that write or delete, file edits',
|
|
282
|
+
'outside the project root, network requests, package installs, deploys,',
|
|
283
|
+
'and any external API call you have not used in this session before.',
|
|
284
|
+
'',
|
|
285
|
+
"The PreToolUse hook installed by `dashclaw install codex` will guard",
|
|
286
|
+
'Bash, Edit, Write, and MultiEdit automatically. The guidance above is',
|
|
287
|
+
'still required for tool calls that fall outside that matcher (MCP tool',
|
|
288
|
+
"invocations, agent-internal capabilities) so DashClaw's audit trail",
|
|
289
|
+
'covers them too.',
|
|
290
|
+
'',
|
|
291
|
+
'### After each action',
|
|
292
|
+
'',
|
|
293
|
+
'Call `dashclaw_record` with the action id (from `dashclaw_guard` or from',
|
|
294
|
+
'a PostToolUse-emitted breadcrumb) and the outcome (`success`,',
|
|
295
|
+
'`failure`, or `partial`). This is what makes the decision replayable.',
|
|
296
|
+
'',
|
|
297
|
+
baseUrl ? `### This instance\n\nDashClaw: ${baseUrl}` : '',
|
|
298
|
+
'',
|
|
299
|
+
AGENTS_MANAGED_END,
|
|
300
|
+
];
|
|
301
|
+
return lines.filter((l) => l !== null).join('\n');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// -----------------------------------------------------------------------------
|
|
305
|
+
// File mutation helpers
|
|
306
|
+
// -----------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
function backupOnce(path) {
|
|
309
|
+
if (!existsSync(path)) return null;
|
|
310
|
+
const bak = path + '.dashclaw-bak';
|
|
311
|
+
if (existsSync(bak)) return bak;
|
|
312
|
+
copyFileSync(path, bak);
|
|
313
|
+
return bak;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function mergeConfigToml({
|
|
317
|
+
configPath,
|
|
318
|
+
mcpServerPath,
|
|
319
|
+
hooksDir,
|
|
320
|
+
approvalPolicy,
|
|
321
|
+
includeNotify = false,
|
|
322
|
+
dashclawCliPath = null,
|
|
323
|
+
}) {
|
|
324
|
+
const before = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
|
|
325
|
+
const block = buildConfigTomlBlock({
|
|
326
|
+
mcpServerPath,
|
|
327
|
+
hooksDir,
|
|
328
|
+
approvalPolicy,
|
|
329
|
+
includeNotify,
|
|
330
|
+
dashclawCliPath,
|
|
331
|
+
});
|
|
332
|
+
const after = replaceManagedBlock(before, block);
|
|
333
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
334
|
+
const backup = backupOnce(configPath);
|
|
335
|
+
writeFileSync(configPath, after);
|
|
336
|
+
return { changed: before !== after, backup };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function mergeAgentsMd({ agentsMdPath, baseUrl }) {
|
|
340
|
+
const before = existsSync(agentsMdPath) ? readFileSync(agentsMdPath, 'utf8') : '';
|
|
341
|
+
const block = buildAgentsMdBlock({ baseUrl });
|
|
342
|
+
const after = replaceManagedBlock(before, block, {
|
|
343
|
+
startMarker: AGENTS_MANAGED_START,
|
|
344
|
+
endMarker: AGENTS_MANAGED_END,
|
|
345
|
+
});
|
|
346
|
+
mkdirSync(dirname(agentsMdPath), { recursive: true });
|
|
347
|
+
const backup = backupOnce(agentsMdPath);
|
|
348
|
+
writeFileSync(agentsMdPath, after);
|
|
349
|
+
return { changed: before !== after, backup };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// -----------------------------------------------------------------------------
|
|
353
|
+
// Top-level install
|
|
354
|
+
// -----------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
export async function installCodex({
|
|
357
|
+
repoRoot,
|
|
358
|
+
projectDir = process.cwd(),
|
|
359
|
+
baseUrl,
|
|
360
|
+
approvalPolicy = 'on-request',
|
|
361
|
+
includeNotify = false,
|
|
362
|
+
env = process.env,
|
|
363
|
+
logger = console,
|
|
364
|
+
}) {
|
|
365
|
+
if (!repoRoot) {
|
|
366
|
+
throw new Error('repoRoot is required (path to the DashClaw checkout)');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const hooksSrc = join(repoRoot, 'hooks');
|
|
370
|
+
const mcpServerPath = join(repoRoot, 'mcp-server', 'bin', 'dashclaw-mcp.js');
|
|
371
|
+
const dashclawCliPath = join(repoRoot, 'cli', 'bin', 'dashclaw.js');
|
|
372
|
+
|
|
373
|
+
if (!existsSync(mcpServerPath)) {
|
|
374
|
+
throw new Error(`MCP server entrypoint missing: ${mcpServerPath}`);
|
|
375
|
+
}
|
|
376
|
+
if (includeNotify && !existsSync(dashclawCliPath)) {
|
|
377
|
+
throw new Error(`dashclaw CLI not found at ${dashclawCliPath} — can't wire notify`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const hooksDst = codexHooksDir(env);
|
|
381
|
+
const configPath = codexConfigPath(env);
|
|
382
|
+
const agentsMdPath = join(projectDir, 'AGENTS.md');
|
|
383
|
+
|
|
384
|
+
logger.info(`Installing DashClaw hooks → ${hooksDst}`);
|
|
385
|
+
const hookResult = copyHooks({ hooksSrc, hooksDst });
|
|
386
|
+
|
|
387
|
+
logger.info(`Merging Codex config → ${configPath}`);
|
|
388
|
+
const configResult = mergeConfigToml({
|
|
389
|
+
configPath,
|
|
390
|
+
mcpServerPath,
|
|
391
|
+
hooksDir: hooksDst,
|
|
392
|
+
approvalPolicy,
|
|
393
|
+
includeNotify,
|
|
394
|
+
dashclawCliPath: includeNotify ? dashclawCliPath : null,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
logger.info(`Merging governance protocol → ${agentsMdPath}`);
|
|
398
|
+
const agentsResult = mergeAgentsMd({ agentsMdPath, baseUrl });
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
hooks: hookResult,
|
|
402
|
+
config: { path: configPath, ...configResult },
|
|
403
|
+
agentsMd: { path: agentsMdPath, ...agentsResult },
|
|
404
|
+
};
|
|
405
|
+
}
|