@massu/core 1.7.0 → 1.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/README.md +89 -0
- package/commands/massu-release.md +23 -1
- package/dist/cli.js +1252 -597
- package/package.json +1 -1
- package/src/changelog-generator.ts +178 -0
- package/src/cli.ts +15 -1
- package/src/commands/changelog.ts +165 -0
- package/src/commands/doctor.ts +4 -3
- package/src/commands/init.ts +3 -10
- package/src/commands/install-commands.ts +62 -53
- package/src/commands/permissions.ts +150 -0
- package/src/lib/settings-local.ts +110 -0
- package/src/permissions.ts +422 -0
- package/src/security/registry-pubkey.generated.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@massu/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "AI Engineering Governance MCP Server - Session memory, knowledge system, feature registry, code intelligence, rule enforcement, tiered tooling (12 free / 72 total), 55+ workflow commands, 11 agents, 20+ patterns",
|
|
6
6
|
"main": "src/server.ts",
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Release-boundary CHANGELOG entry generator. Reads `git log <last-tag>..HEAD`
|
|
6
|
+
* commit subjects, groups by `(plan-<token>)` paren-notation prefix, looks up
|
|
7
|
+
* the matching `docs/plans/*.md` files for their `## Changelog Summary` section,
|
|
8
|
+
* and emits a Keep-a-Changelog 1.1.0-compliant entry.
|
|
9
|
+
*
|
|
10
|
+
* Plan-token regex mirrors `scripts/lib/plan-token-regex.sh` (SSOT). See
|
|
11
|
+
* plan-1.9.0-plan-token-aware-changelog-batcher Phase B.
|
|
12
|
+
*
|
|
13
|
+
* Pure functions only — caller threads in commit subjects + plan directory.
|
|
14
|
+
* No git invocations inside this module (those live in commands/changelog.ts).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
18
|
+
import { resolve } from 'path';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Plan-token regex (TS shim of scripts/lib/plan-token-regex.sh PLAN_TOKEN_REGEX).
|
|
22
|
+
* Matches subject prefix `<type>(plan-<token>):` where <type> ∈ {feat, fix, chore, docs}.
|
|
23
|
+
* Captures: group 1 = type, group 2 = full plan-<token>.
|
|
24
|
+
*/
|
|
25
|
+
const PLAN_TOKEN_RE = /^(feat|fix|chore|docs)\((plan-[a-z0-9._-]+)\)/;
|
|
26
|
+
|
|
27
|
+
export class MissingPlanFileError extends Error {
|
|
28
|
+
constructor(token: string) {
|
|
29
|
+
super(`No plan file found in plans directory matching Plan Token: ${token}`);
|
|
30
|
+
this.name = 'MissingPlanFileError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class MissingChangelogSummaryError extends Error {
|
|
35
|
+
constructor(token: string, planFile: string) {
|
|
36
|
+
super(`Plan file ${planFile} for token ${token} has no '## Changelog Summary' section`);
|
|
37
|
+
this.name = 'MissingChangelogSummaryError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PlanSummary {
|
|
42
|
+
title: string;
|
|
43
|
+
summary: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ParseResult {
|
|
47
|
+
tokens: Set<string>;
|
|
48
|
+
maintenance: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse commit subject lines into:
|
|
53
|
+
* - `tokens`: Set of unique `plan-<token>` strings (with `plan-` prefix)
|
|
54
|
+
* - `maintenance`: subjects that DID NOT match the paren-notation pattern
|
|
55
|
+
*/
|
|
56
|
+
export function parseCommitsForPlanTokens(subjects: readonly string[]): ParseResult {
|
|
57
|
+
const tokens = new Set<string>();
|
|
58
|
+
const maintenance: string[] = [];
|
|
59
|
+
for (const subject of subjects) {
|
|
60
|
+
const m = subject.match(PLAN_TOKEN_RE);
|
|
61
|
+
if (m && m[2]) {
|
|
62
|
+
tokens.add(m[2]);
|
|
63
|
+
} else {
|
|
64
|
+
maintenance.push(subject);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { tokens, maintenance };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* For each plan-token, find the corresponding `docs/plans/*.md` file and
|
|
72
|
+
* extract its title (first `# Plan: ...` heading) and `## Changelog Summary`
|
|
73
|
+
* section body. Throws on missing file or missing section.
|
|
74
|
+
*/
|
|
75
|
+
export function loadPlanSummaries(
|
|
76
|
+
tokens: ReadonlySet<string>,
|
|
77
|
+
planDir: string,
|
|
78
|
+
): Map<string, PlanSummary> {
|
|
79
|
+
const result = new Map<string, PlanSummary>();
|
|
80
|
+
if (tokens.size === 0) return result;
|
|
81
|
+
|
|
82
|
+
if (!existsSync(planDir)) {
|
|
83
|
+
throw new Error(`Plan directory does not exist: ${planDir}`);
|
|
84
|
+
}
|
|
85
|
+
const files = readdirSync(planDir).filter((f) => f.endsWith('.md'));
|
|
86
|
+
|
|
87
|
+
for (const token of tokens) {
|
|
88
|
+
let matchedFile: string | null = null;
|
|
89
|
+
let content = '';
|
|
90
|
+
for (const file of files) {
|
|
91
|
+
const path = resolve(planDir, file);
|
|
92
|
+
const text = readFileSync(path, 'utf-8');
|
|
93
|
+
// Match `**Plan Token**: `plan-<token>`` allowing optional surrounding text.
|
|
94
|
+
const tokenRe = new RegExp(
|
|
95
|
+
`^\\*\\*Plan Token\\*\\*:\\s*\`?${token.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\`?(\\s|$)`,
|
|
96
|
+
'm',
|
|
97
|
+
);
|
|
98
|
+
if (tokenRe.test(text)) {
|
|
99
|
+
matchedFile = file;
|
|
100
|
+
content = text;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!matchedFile) {
|
|
105
|
+
throw new MissingPlanFileError(token);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Extract title: first line starting with `# ` (after the metadata block).
|
|
109
|
+
const titleMatch = content.match(/^# (.+)$/m);
|
|
110
|
+
const title = titleMatch ? titleMatch[1].trim() : token;
|
|
111
|
+
|
|
112
|
+
// Extract `## Changelog Summary` section body.
|
|
113
|
+
const sectionRe = /^## Changelog Summary\s*\n([\s\S]*?)(?=\n## |\n---|\n# |$)/m;
|
|
114
|
+
const sectionMatch = content.match(sectionRe);
|
|
115
|
+
if (!sectionMatch || !sectionMatch[1].trim()) {
|
|
116
|
+
throw new MissingChangelogSummaryError(token, matchedFile);
|
|
117
|
+
}
|
|
118
|
+
const summary = sectionMatch[1].trim();
|
|
119
|
+
result.set(token, { title, summary });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface GenerateOptions {
|
|
126
|
+
version: string;
|
|
127
|
+
date: string;
|
|
128
|
+
planSummaries: ReadonlyMap<string, PlanSummary>;
|
|
129
|
+
maintenance: readonly string[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Emit a CHANGELOG.md entry in Keep-a-Changelog 1.1.0 format. Each plan-token
|
|
134
|
+
* becomes one paragraph (or `### Added` subsection if the plan summary itself
|
|
135
|
+
* uses Keep-a-Changelog subsections). Maintenance commits go under a
|
|
136
|
+
* `### Maintenance` subsection at the end.
|
|
137
|
+
*/
|
|
138
|
+
export function generateChangelogEntry(opts: GenerateOptions): string {
|
|
139
|
+
const parts: string[] = [];
|
|
140
|
+
parts.push(`## [${opts.version}] - ${opts.date}\n`);
|
|
141
|
+
parts.push('');
|
|
142
|
+
|
|
143
|
+
// Plan summaries, in iteration order
|
|
144
|
+
for (const [, planSum] of opts.planSummaries) {
|
|
145
|
+
parts.push(planSum.summary);
|
|
146
|
+
parts.push('');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Maintenance fallback
|
|
150
|
+
if (opts.maintenance.length > 0) {
|
|
151
|
+
parts.push('### Maintenance');
|
|
152
|
+
parts.push('');
|
|
153
|
+
for (const subject of opts.maintenance) {
|
|
154
|
+
parts.push(`- ${subject}`);
|
|
155
|
+
}
|
|
156
|
+
parts.push('');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return parts.join('\n') + '\n';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Given a CHANGELOG.md entry body and a set of plan-tokens that should be
|
|
164
|
+
* referenced, return the subset of tokens NOT mentioned in the body.
|
|
165
|
+
* Used by `permissions check-drift` verification + pre-tag gate.
|
|
166
|
+
*/
|
|
167
|
+
export function findCoverageGaps(
|
|
168
|
+
entryText: string,
|
|
169
|
+
tokens: ReadonlySet<string>,
|
|
170
|
+
): string[] {
|
|
171
|
+
const gaps: string[] = [];
|
|
172
|
+
for (const token of tokens) {
|
|
173
|
+
if (!entryText.includes(token)) {
|
|
174
|
+
gaps.push(token);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return gaps;
|
|
178
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -47,6 +47,18 @@ async function main(): Promise<void> {
|
|
|
47
47
|
await runInstallCommands();
|
|
48
48
|
break;
|
|
49
49
|
}
|
|
50
|
+
case 'permissions': {
|
|
51
|
+
const { handlePermissionsSubcommand } = await import('./commands/permissions.ts');
|
|
52
|
+
const result = await handlePermissionsSubcommand(args.slice(1));
|
|
53
|
+
process.exit(result.exitCode);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
case 'changelog': {
|
|
57
|
+
const { handleChangelogSubcommand } = await import('./commands/changelog.ts');
|
|
58
|
+
const result = await handleChangelogSubcommand(args.slice(1));
|
|
59
|
+
process.exit(result.exitCode);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
50
62
|
case 'show-template': {
|
|
51
63
|
const { runShowTemplate } = await import('./commands/show-template.ts');
|
|
52
64
|
await runShowTemplate(args.slice(1));
|
|
@@ -162,12 +174,14 @@ Commands:
|
|
|
162
174
|
init Set up Massu AI in your project (one command, full setup)
|
|
163
175
|
doctor Check installation health
|
|
164
176
|
install-hooks Install/update Claude Code hooks
|
|
165
|
-
install-commands Install/update slash commands
|
|
177
|
+
install-commands Install/update slash commands (use --skip-permissions to opt out of MCP allowlist seeding)
|
|
166
178
|
show-template Print the resolved variant of a bundled template (e.g. for diffs)
|
|
167
179
|
watch Run the file-watcher daemon (auto-refresh on stack changes)
|
|
168
180
|
refresh-log [N] Show the last N watcher auto-refresh events
|
|
169
181
|
validate-config Validate massu.config.yaml (alias: config validate)
|
|
170
182
|
config <sub> Config lifecycle: refresh | validate | upgrade | doctor | check-drift
|
|
183
|
+
permissions <sub> MCP permission lifecycle: install | verify | check-drift
|
|
184
|
+
changelog <sub> CHANGELOG generation / verification: generate | verify
|
|
171
185
|
adapters <sub> Third-party adapter registry: list | refresh | search | add-local | remove-local | install | resign
|
|
172
186
|
|
|
173
187
|
Options:
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `massu changelog <subcommand>` — CHANGELOG generation + verification CLI.
|
|
6
|
+
*
|
|
7
|
+
* Subcommands:
|
|
8
|
+
* generate Emit a draft CHANGELOG.md entry to stdout for commits since the
|
|
9
|
+
* last tag. Operator pipes/copies into CHANGELOG.md.
|
|
10
|
+
* verify Read current CHANGELOG.md latest entry; verify every plan-token
|
|
11
|
+
* in `git log <last-tag>..HEAD` subjects is referenced. Exit 0 if
|
|
12
|
+
* clean, exit 1 with one `gap: <token>` per missing.
|
|
13
|
+
*
|
|
14
|
+
* Plan ref: plan-1.9.0-plan-token-aware-changelog-batcher Phase C.
|
|
15
|
+
* Mirrors the `permissions <sub>` cluster precedent shipped in 1.8.0.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execSync } from 'child_process';
|
|
19
|
+
import { existsSync, readFileSync } from 'fs';
|
|
20
|
+
import { resolve } from 'path';
|
|
21
|
+
import {
|
|
22
|
+
parseCommitsForPlanTokens,
|
|
23
|
+
loadPlanSummaries,
|
|
24
|
+
generateChangelogEntry,
|
|
25
|
+
findCoverageGaps,
|
|
26
|
+
MissingPlanFileError,
|
|
27
|
+
MissingChangelogSummaryError,
|
|
28
|
+
} from '../changelog-generator.ts';
|
|
29
|
+
|
|
30
|
+
function resolveRepoRoot(): string {
|
|
31
|
+
try {
|
|
32
|
+
return execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim();
|
|
33
|
+
} catch {
|
|
34
|
+
return process.cwd();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getLastTag(): string | null {
|
|
39
|
+
try {
|
|
40
|
+
return execSync('git describe --tags --abbrev=0', { encoding: 'utf-8' }).trim();
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getCommitSubjects(range: string): string[] {
|
|
47
|
+
try {
|
|
48
|
+
const out = execSync(`git log ${range} --pretty=format:%s`, { encoding: 'utf-8' });
|
|
49
|
+
return out.split('\n').filter((s) => s.length > 0);
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getCurrentVersion(repoRoot: string): string {
|
|
56
|
+
const pkgPath = resolve(repoRoot, 'packages/core/package.json');
|
|
57
|
+
if (!existsSync(pkgPath)) return '0.0.0';
|
|
58
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
59
|
+
return pkg.version || '0.0.0';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function todayDate(): string {
|
|
63
|
+
return new Date().toISOString().slice(0, 10);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getLatestChangelogEntryBody(repoRoot: string): string {
|
|
67
|
+
const path = resolve(repoRoot, 'CHANGELOG.md');
|
|
68
|
+
if (!existsSync(path)) return '';
|
|
69
|
+
const content = readFileSync(path, 'utf-8');
|
|
70
|
+
// Find first `## [...]` heading and capture until next or EOF
|
|
71
|
+
const m = content.match(/^## \[[\d.]+\][^\n]*\n([\s\S]*?)(?=\n## \[|$)/m);
|
|
72
|
+
return m ? m[1] : '';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function handleChangelogSubcommand(
|
|
76
|
+
args: string[],
|
|
77
|
+
): Promise<{ exitCode: number }> {
|
|
78
|
+
const sub = args[0];
|
|
79
|
+
const repoRoot = resolveRepoRoot();
|
|
80
|
+
const planDir = resolve(repoRoot, 'docs/plans');
|
|
81
|
+
|
|
82
|
+
switch (sub) {
|
|
83
|
+
case 'generate': {
|
|
84
|
+
const lastTag = getLastTag();
|
|
85
|
+
const range = lastTag ? `${lastTag}..HEAD` : 'HEAD';
|
|
86
|
+
const subjects = getCommitSubjects(range);
|
|
87
|
+
const { tokens, maintenance } = parseCommitsForPlanTokens(subjects);
|
|
88
|
+
|
|
89
|
+
let planSummaries;
|
|
90
|
+
try {
|
|
91
|
+
planSummaries = loadPlanSummaries(tokens, planDir);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (err instanceof MissingPlanFileError || err instanceof MissingChangelogSummaryError) {
|
|
94
|
+
process.stderr.write(`changelog generate: ${err.message}\n`);
|
|
95
|
+
return { exitCode: 2 };
|
|
96
|
+
}
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const entry = generateChangelogEntry({
|
|
101
|
+
version: getCurrentVersion(repoRoot),
|
|
102
|
+
date: todayDate(),
|
|
103
|
+
planSummaries,
|
|
104
|
+
maintenance,
|
|
105
|
+
});
|
|
106
|
+
process.stdout.write(entry);
|
|
107
|
+
return { exitCode: 0 };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
case 'verify': {
|
|
111
|
+
const lastTag = getLastTag();
|
|
112
|
+
const range = lastTag ? `${lastTag}..HEAD` : 'HEAD';
|
|
113
|
+
const subjects = getCommitSubjects(range);
|
|
114
|
+
const { tokens } = parseCommitsForPlanTokens(subjects);
|
|
115
|
+
|
|
116
|
+
const entryBody = getLatestChangelogEntryBody(repoRoot);
|
|
117
|
+
const gaps = findCoverageGaps(entryBody, tokens);
|
|
118
|
+
|
|
119
|
+
if (gaps.length === 0) {
|
|
120
|
+
process.stdout.write('All plan-tokens referenced.\n');
|
|
121
|
+
return { exitCode: 0 };
|
|
122
|
+
}
|
|
123
|
+
for (const t of gaps) {
|
|
124
|
+
process.stderr.write(`gap: ${t}\n`);
|
|
125
|
+
}
|
|
126
|
+
return { exitCode: 1 };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case '--help':
|
|
130
|
+
case '-h':
|
|
131
|
+
case undefined: {
|
|
132
|
+
printChangelogHelp();
|
|
133
|
+
return { exitCode: 0 };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
default: {
|
|
137
|
+
process.stderr.write(`massu: unknown changelog subcommand: ${sub}\n`);
|
|
138
|
+
printChangelogHelp();
|
|
139
|
+
return { exitCode: 1 };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function printChangelogHelp(): void {
|
|
145
|
+
process.stdout.write(`
|
|
146
|
+
massu changelog <subcommand>
|
|
147
|
+
|
|
148
|
+
Subcommands:
|
|
149
|
+
generate Emit a draft CHANGELOG.md entry to stdout. Reads commit subjects
|
|
150
|
+
since the last git tag, groups by (plan-<token>) paren-notation,
|
|
151
|
+
looks up each plan file's ## Changelog Summary section, and emits
|
|
152
|
+
a Keep-a-Changelog 1.1.0 entry. Operator pipes/copies into
|
|
153
|
+
CHANGELOG.md (no forced overwrite).
|
|
154
|
+
|
|
155
|
+
verify Read-only check that the latest CHANGELOG.md entry references
|
|
156
|
+
every plan-token in commits since the last tag. Exit 0 if clean,
|
|
157
|
+
exit 1 with one 'gap: <token>' per missing.
|
|
158
|
+
|
|
159
|
+
Examples:
|
|
160
|
+
npx massu changelog generate > /tmp/draft-entry.md
|
|
161
|
+
npx massu changelog verify
|
|
162
|
+
|
|
163
|
+
Documentation: https://massu.ai/docs/reference/cli-reference#massu-changelog
|
|
164
|
+
`);
|
|
165
|
+
}
|
package/src/commands/doctor.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { fileURLToPath } from 'url';
|
|
|
23
23
|
import { parse as parseYaml } from 'yaml';
|
|
24
24
|
import { getConfig, getResolvedPaths } from '../config.ts';
|
|
25
25
|
import { getCurrentTier, getLicenseInfo, daysUntilExpiry } from '../license.ts';
|
|
26
|
+
import { readSettingsAtPath } from '../lib/settings-local.ts';
|
|
26
27
|
|
|
27
28
|
const __filename = fileURLToPath(import.meta.url);
|
|
28
29
|
const __dirname = dirname(__filename);
|
|
@@ -103,7 +104,7 @@ function checkHooksConfig(projectRoot: string): CheckResult {
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
try {
|
|
106
|
-
const content =
|
|
107
|
+
const content = readSettingsAtPath(settingsPath);
|
|
107
108
|
if (!content.hooks) {
|
|
108
109
|
return { name: 'Hooks Config', status: 'fail', detail: 'No hooks configured. Run: npx massu install-hooks' };
|
|
109
110
|
}
|
|
@@ -238,8 +239,8 @@ function checkShellHooksWired(_projectRoot: string): CheckResult {
|
|
|
238
239
|
}
|
|
239
240
|
|
|
240
241
|
try {
|
|
241
|
-
const content =
|
|
242
|
-
const hooks = content.hooks ?? {}
|
|
242
|
+
const content = readSettingsAtPath(settingsPath);
|
|
243
|
+
const hooks = (content.hooks ?? {}) as Record<string, unknown>;
|
|
243
244
|
const hasSessionStart = Array.isArray(hooks.SessionStart) && hooks.SessionStart.length > 0;
|
|
244
245
|
const hasPreToolUse = Array.isArray(hooks.PreToolUse) && hooks.PreToolUse.length > 0;
|
|
245
246
|
if (!hasSessionStart && !hasPreToolUse) {
|
package/src/commands/init.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
|
|
|
34
34
|
import { backfillMemoryFiles } from '../memory-file-ingest.ts';
|
|
35
35
|
import { getConfig, resetConfig } from '../config.ts';
|
|
36
36
|
import { installAll } from './install-commands.ts';
|
|
37
|
+
import { readSettingsLocal, writeSettingsLocalAtomic } from '../lib/settings-local.ts';
|
|
37
38
|
import {
|
|
38
39
|
runDetection,
|
|
39
40
|
type DetectionResult,
|
|
@@ -1107,20 +1108,12 @@ export function installHooks(projectRoot: string): { installed: boolean; count:
|
|
|
1107
1108
|
claudeDirName = '.claude';
|
|
1108
1109
|
}
|
|
1109
1110
|
const claudeDir = resolve(projectRoot, claudeDirName);
|
|
1110
|
-
const settingsPath = resolve(claudeDir, 'settings.local.json');
|
|
1111
1111
|
|
|
1112
1112
|
if (!existsSync(claudeDir)) {
|
|
1113
1113
|
mkdirSync(claudeDir, { recursive: true });
|
|
1114
1114
|
}
|
|
1115
1115
|
|
|
1116
|
-
|
|
1117
|
-
if (existsSync(settingsPath)) {
|
|
1118
|
-
try {
|
|
1119
|
-
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
1120
|
-
} catch {
|
|
1121
|
-
settings = {};
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1116
|
+
const settings = readSettingsLocal(claudeDir);
|
|
1124
1117
|
|
|
1125
1118
|
const hooksDir = resolveHooksDir();
|
|
1126
1119
|
const hooksConfig = buildHooksConfig(hooksDir);
|
|
@@ -1134,7 +1127,7 @@ export function installHooks(projectRoot: string): { installed: boolean; count:
|
|
|
1134
1127
|
|
|
1135
1128
|
settings.hooks = hooksConfig;
|
|
1136
1129
|
|
|
1137
|
-
|
|
1130
|
+
writeSettingsLocalAtomic(claudeDir, settings);
|
|
1138
1131
|
|
|
1139
1132
|
return { installed: true, count: hookCount };
|
|
1140
1133
|
}
|
|
@@ -20,17 +20,11 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import {
|
|
23
|
-
closeSync,
|
|
24
23
|
existsSync,
|
|
25
|
-
fsyncSync,
|
|
26
|
-
openSync,
|
|
27
24
|
readFileSync,
|
|
28
|
-
rmSync,
|
|
29
|
-
writeSync,
|
|
30
25
|
mkdirSync,
|
|
31
26
|
readdirSync,
|
|
32
27
|
statSync,
|
|
33
|
-
renameSync,
|
|
34
28
|
} from 'fs';
|
|
35
29
|
import { resolve, dirname, relative, join } from 'path';
|
|
36
30
|
import { fileURLToPath } from 'url';
|
|
@@ -38,6 +32,8 @@ import { createHash } from 'crypto';
|
|
|
38
32
|
import { getConfig } from '../config.ts';
|
|
39
33
|
import type { Config } from '../config.ts';
|
|
40
34
|
import { renderTemplate, MissingVariableError, TemplateParseError } from './template-engine.ts';
|
|
35
|
+
import { atomicWriteFile } from '../lib/settings-local.ts';
|
|
36
|
+
import { installPermissions } from '../permissions.ts';
|
|
41
37
|
|
|
42
38
|
const __filename = fileURLToPath(import.meta.url);
|
|
43
39
|
const __dirname = dirname(__filename);
|
|
@@ -131,47 +127,7 @@ export function loadManifest(claudeDir: string): Manifest {
|
|
|
131
127
|
}
|
|
132
128
|
}
|
|
133
129
|
|
|
134
|
-
/**
|
|
135
|
-
* Iter-7 fix: atomic file write — tmp + fsync + rename.
|
|
136
|
-
*
|
|
137
|
-
* Plan 3a §3 Risk #4 ("Watcher writes to .claude/ while editor has it open:
|
|
138
|
-
* editors can lose changes. Mitigation: write to .massu-tmp then atomic rename")
|
|
139
|
-
* AND the watcher spec doc §3 Shutdown Semantics claim ("every file op the
|
|
140
|
-
* refresh issues is already atomic-rename-safe... installAll writes <path>.tmp
|
|
141
|
-
* then renameSync") both demand this. Previously installAll's per-file writes
|
|
142
|
-
* (lines 463/467) and saveManifest were direct `writeFileSync` calls — a
|
|
143
|
-
* SIGINT/power-loss between truncate and complete-write left a partial file.
|
|
144
|
-
* Now both go through this helper so the watcher's iter-6 "we don't await
|
|
145
|
-
* fireRefresh because atomic-rename covers everything" decision is sound.
|
|
146
|
-
*
|
|
147
|
-
* Writes via openSync + writeSync + fsyncSync + closeSync + renameSync so the
|
|
148
|
-
* data hits the platter before the rename. On any error, removes the tmp file.
|
|
149
|
-
* Tmp filename includes process.pid to avoid clashes with concurrent installs
|
|
150
|
-
* from sibling processes (e.g. a manual `npx massu config refresh` racing the
|
|
151
|
-
* watcher daemon — the install-lock should prevent this, but the file-level
|
|
152
|
-
* tmp name disambiguates if it ever happens).
|
|
153
|
-
*/
|
|
154
|
-
function atomicWriteFile(targetPath: string, content: string, mode = 0o644): void {
|
|
155
|
-
const tmpPath = `${targetPath}.${process.pid}.tmp`;
|
|
156
|
-
try {
|
|
157
|
-
const fd = openSync(tmpPath, 'w', mode);
|
|
158
|
-
try {
|
|
159
|
-
const buf = Buffer.from(content, 'utf-8');
|
|
160
|
-
writeSync(fd, buf, 0, buf.length, 0);
|
|
161
|
-
fsyncSync(fd);
|
|
162
|
-
} finally {
|
|
163
|
-
closeSync(fd);
|
|
164
|
-
}
|
|
165
|
-
renameSync(tmpPath, targetPath);
|
|
166
|
-
} catch (err) {
|
|
167
|
-
if (existsSync(tmpPath)) {
|
|
168
|
-
try { rmSync(tmpPath, { force: true }); } catch { /* ignore */ }
|
|
169
|
-
}
|
|
170
|
-
throw err;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Write the manifest atomically: tempfile + fsync + renameSync. */
|
|
130
|
+
/** Write the manifest atomically: tempfile + fsync + renameSync (uses shared lib/settings-local.ts:atomicWriteFile). */
|
|
175
131
|
export function saveManifest(claudeDir: string, manifest: Manifest): void {
|
|
176
132
|
const dir = resolve(claudeDir, '.massu');
|
|
177
133
|
if (!existsSync(dir)) {
|
|
@@ -541,7 +497,15 @@ export function buildTemplateVars(): Record<string, unknown> {
|
|
|
541
497
|
};
|
|
542
498
|
}
|
|
543
499
|
|
|
544
|
-
export
|
|
500
|
+
export interface InstallCommandsOptions {
|
|
501
|
+
/** When true, skip seeding `mcp__massu__*` into permissions.allow. */
|
|
502
|
+
skipPermissions?: boolean;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function installCommands(
|
|
506
|
+
projectRoot: string,
|
|
507
|
+
opts: InstallCommandsOptions = {},
|
|
508
|
+
): InstallCommandsResult {
|
|
545
509
|
const claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
|
|
546
510
|
const claudeDir = resolve(projectRoot, claudeDirName);
|
|
547
511
|
const targetDir = resolve(claudeDir, 'commands');
|
|
@@ -559,9 +523,21 @@ export function installCommands(projectRoot: string): InstallCommandsResult {
|
|
|
559
523
|
|
|
560
524
|
const framework = getConfig().framework;
|
|
561
525
|
const templateVars = buildTemplateVars();
|
|
562
|
-
const stats = runWithManifest(claudeDir, (manifest) =>
|
|
563
|
-
|
|
564
|
-
|
|
526
|
+
const stats = runWithManifest(claudeDir, (manifest) => {
|
|
527
|
+
const syncStats = syncDirectory(
|
|
528
|
+
sourceDir,
|
|
529
|
+
targetDir,
|
|
530
|
+
framework,
|
|
531
|
+
manifest,
|
|
532
|
+
'commands',
|
|
533
|
+
true,
|
|
534
|
+
templateVars,
|
|
535
|
+
);
|
|
536
|
+
if (!opts.skipPermissions) {
|
|
537
|
+
installPermissions(claudeDir, manifest, { silent: true });
|
|
538
|
+
}
|
|
539
|
+
return syncStats;
|
|
540
|
+
});
|
|
565
541
|
return { ...stats, commandsDir: targetDir };
|
|
566
542
|
}
|
|
567
543
|
|
|
@@ -576,9 +552,19 @@ export interface InstallAllResult {
|
|
|
576
552
|
totalSkipped: number;
|
|
577
553
|
totalKept: number;
|
|
578
554
|
claudeDir: string;
|
|
555
|
+
/** Permission-seeding outcome (undefined when --skip-permissions). */
|
|
556
|
+
permissions?: { installed: number; kept: number; skipped: number };
|
|
579
557
|
}
|
|
580
558
|
|
|
581
|
-
export
|
|
559
|
+
export interface InstallAllOptions {
|
|
560
|
+
/** When true, skip seeding `mcp__massu__*` into permissions.allow. */
|
|
561
|
+
skipPermissions?: boolean;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function installAll(
|
|
565
|
+
projectRoot: string,
|
|
566
|
+
opts: InstallAllOptions = {},
|
|
567
|
+
): InstallAllResult {
|
|
582
568
|
const claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
|
|
583
569
|
const claudeDir = resolve(projectRoot, claudeDirName);
|
|
584
570
|
|
|
@@ -587,6 +573,7 @@ export function installAll(projectRoot: string): InstallAllResult {
|
|
|
587
573
|
let totalUpdated = 0;
|
|
588
574
|
let totalSkipped = 0;
|
|
589
575
|
let totalKept = 0;
|
|
576
|
+
let permissionsResult: { installed: number; kept: number; skipped: number } | undefined;
|
|
590
577
|
|
|
591
578
|
const framework = getConfig().framework;
|
|
592
579
|
const templateVars = buildTemplateVars();
|
|
@@ -613,6 +600,9 @@ export function installAll(projectRoot: string): InstallAllResult {
|
|
|
613
600
|
totalSkipped += stats.skipped;
|
|
614
601
|
totalKept += stats.kept;
|
|
615
602
|
}
|
|
603
|
+
if (!opts.skipPermissions) {
|
|
604
|
+
permissionsResult = installPermissions(claudeDir, manifest, { silent: true });
|
|
605
|
+
}
|
|
616
606
|
});
|
|
617
607
|
|
|
618
608
|
return {
|
|
@@ -622,6 +612,7 @@ export function installAll(projectRoot: string): InstallAllResult {
|
|
|
622
612
|
totalSkipped,
|
|
623
613
|
totalKept,
|
|
624
614
|
claudeDir,
|
|
615
|
+
permissions: permissionsResult,
|
|
625
616
|
};
|
|
626
617
|
}
|
|
627
618
|
|
|
@@ -631,13 +622,14 @@ export function installAll(projectRoot: string): InstallAllResult {
|
|
|
631
622
|
|
|
632
623
|
export async function runInstallCommands(): Promise<void> {
|
|
633
624
|
const projectRoot = process.cwd();
|
|
625
|
+
const skipPermissions = process.argv.slice(2).includes('--skip-permissions');
|
|
634
626
|
|
|
635
627
|
console.log('');
|
|
636
628
|
console.log('Massu AI - Install Project Assets');
|
|
637
629
|
console.log('==================================');
|
|
638
630
|
console.log('');
|
|
639
631
|
|
|
640
|
-
const result = installAll(projectRoot);
|
|
632
|
+
const result = installAll(projectRoot, { skipPermissions });
|
|
641
633
|
|
|
642
634
|
// Report per-asset-type
|
|
643
635
|
for (const assetType of ASSET_TYPES) {
|
|
@@ -667,6 +659,23 @@ export async function runInstallCommands(): Promise<void> {
|
|
|
667
659
|
` ${result.totalKept} file(s) had local edits and were preserved (see stderr above).`,
|
|
668
660
|
);
|
|
669
661
|
}
|
|
662
|
+
|
|
663
|
+
// Permission seeding outcome line
|
|
664
|
+
if (skipPermissions) {
|
|
665
|
+
console.log(' Permission seeding skipped (--skip-permissions).');
|
|
666
|
+
} else if (result.permissions) {
|
|
667
|
+
if (result.permissions.installed > 0) {
|
|
668
|
+
console.log(
|
|
669
|
+
` Wrote merged permissions block to .claude/settings.local.json (use --skip-permissions to opt out).`,
|
|
670
|
+
);
|
|
671
|
+
} else if (result.permissions.kept > 0) {
|
|
672
|
+
console.log(
|
|
673
|
+
` MCP allowlist entry was edited by operator; preserved. Use \`npx massu permissions check-drift\` to inspect.`,
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
// skipped:1 → silent (already in sync, no operator-visible change)
|
|
677
|
+
}
|
|
678
|
+
|
|
670
679
|
console.log('');
|
|
671
680
|
console.log(' Restart your Claude Code session to use them.');
|
|
672
681
|
console.log('');
|