@soleri/cli 9.3.1 → 9.5.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/dist/commands/agent.js +51 -2
- package/dist/commands/agent.js.map +1 -1
- package/dist/commands/hooks.js +126 -0
- package/dist/commands/hooks.js.map +1 -1
- package/dist/commands/install.js +5 -0
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/pack.js +62 -13
- package/dist/commands/pack.js.map +1 -1
- package/dist/commands/staging.d.ts +49 -0
- package/dist/commands/staging.js +108 -18
- package/dist/commands/staging.js.map +1 -1
- package/dist/commands/yolo.d.ts +2 -0
- package/dist/commands/yolo.js +86 -0
- package/dist/commands/yolo.js.map +1 -0
- package/dist/hook-packs/converter/README.md +99 -0
- package/dist/hook-packs/converter/template.d.ts +36 -0
- package/dist/hook-packs/converter/template.js +127 -0
- package/dist/hook-packs/converter/template.js.map +1 -0
- package/dist/hook-packs/converter/template.test.ts +133 -0
- package/dist/hook-packs/converter/template.ts +163 -0
- package/dist/hook-packs/flock-guard/README.md +65 -0
- package/dist/hook-packs/flock-guard/manifest.json +36 -0
- package/dist/hook-packs/flock-guard/scripts/flock-guard-post.sh +48 -0
- package/dist/hook-packs/flock-guard/scripts/flock-guard-pre.sh +85 -0
- package/dist/hook-packs/full/manifest.json +8 -1
- package/dist/hook-packs/graduation.d.ts +11 -0
- package/dist/hook-packs/graduation.js +48 -0
- package/dist/hook-packs/graduation.js.map +1 -0
- package/dist/hook-packs/graduation.ts +65 -0
- package/dist/hook-packs/installer.js +3 -1
- package/dist/hook-packs/installer.js.map +1 -1
- package/dist/hook-packs/installer.ts +3 -1
- package/dist/hook-packs/marketing-research/README.md +37 -0
- package/dist/hook-packs/marketing-research/manifest.json +24 -0
- package/dist/hook-packs/marketing-research/scripts/marketing-research.sh +70 -0
- package/dist/hook-packs/registry.d.ts +1 -0
- package/dist/hook-packs/registry.js +14 -4
- package/dist/hook-packs/registry.js.map +1 -1
- package/dist/hook-packs/registry.ts +18 -4
- package/dist/hook-packs/safety/README.md +50 -0
- package/dist/hook-packs/safety/manifest.json +23 -0
- package/dist/hook-packs/safety/scripts/anti-deletion.sh +280 -0
- package/dist/hook-packs/validator.d.ts +32 -0
- package/dist/hook-packs/validator.js +126 -0
- package/dist/hook-packs/validator.js.map +1 -0
- package/dist/hook-packs/validator.ts +158 -0
- package/dist/hook-packs/yolo-safety/manifest.json +3 -19
- package/dist/hook-packs/yolo-safety/scripts/anti-deletion.sh +121 -61
- package/dist/main.js +2 -0
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/flock-guard.test.ts +225 -0
- package/src/__tests__/graduation.test.ts +199 -0
- package/src/__tests__/hook-packs.test.ts +45 -20
- package/src/__tests__/hooks-convert.test.ts +342 -0
- package/src/__tests__/validator.test.ts +265 -0
- package/src/__tests__/wizard-e2e.mjs +1 -1
- package/src/commands/agent.ts +65 -2
- package/src/commands/hooks.ts +172 -0
- package/src/commands/install.ts +6 -0
- package/src/commands/pack.ts +80 -14
- package/src/commands/staging.ts +143 -20
- package/src/commands/yolo.ts +103 -0
- package/src/hook-packs/converter/README.md +99 -0
- package/src/hook-packs/converter/template.test.ts +133 -0
- package/src/hook-packs/converter/template.ts +163 -0
- package/src/hook-packs/flock-guard/README.md +65 -0
- package/src/hook-packs/flock-guard/manifest.json +36 -0
- package/src/hook-packs/flock-guard/scripts/flock-guard-post.sh +48 -0
- package/src/hook-packs/flock-guard/scripts/flock-guard-pre.sh +85 -0
- package/src/hook-packs/full/manifest.json +8 -1
- package/src/hook-packs/graduation.ts +65 -0
- package/src/hook-packs/installer.ts +3 -1
- package/src/hook-packs/marketing-research/README.md +37 -0
- package/src/hook-packs/marketing-research/manifest.json +24 -0
- package/src/hook-packs/marketing-research/scripts/marketing-research.sh +70 -0
- package/src/hook-packs/registry.ts +18 -4
- package/src/hook-packs/safety/README.md +50 -0
- package/src/hook-packs/safety/manifest.json +23 -0
- package/src/hook-packs/safety/scripts/anti-deletion.sh +280 -0
- package/src/hook-packs/validator.ts +158 -0
- package/src/hook-packs/yolo-safety/manifest.json +3 -19
- package/src/main.ts +2 -0
- package/vitest.config.ts +1 -0
- package/src/__tests__/archetypes.test.ts +0 -84
- package/src/__tests__/create.test.ts +0 -207
- package/src/hook-packs/yolo-safety/scripts/anti-deletion.sh +0 -214
- package/src/prompts/archetypes.ts +0 -343
package/src/main.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { registerSkills } from './commands/skills.js';
|
|
|
21
21
|
import { registerAgent } from './commands/agent.js';
|
|
22
22
|
import { registerTelegram } from './commands/telegram.js';
|
|
23
23
|
import { registerStaging } from './commands/staging.js';
|
|
24
|
+
import { registerYolo } from './commands/yolo.js';
|
|
24
25
|
|
|
25
26
|
const require = createRequire(import.meta.url);
|
|
26
27
|
const { version } = require('../package.json');
|
|
@@ -82,4 +83,5 @@ registerSkills(program);
|
|
|
82
83
|
registerAgent(program);
|
|
83
84
|
registerTelegram(program);
|
|
84
85
|
registerStaging(program);
|
|
86
|
+
registerYolo(program);
|
|
85
87
|
program.parse();
|
package/vitest.config.ts
CHANGED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { ARCHETYPES } from '../prompts/archetypes.js';
|
|
3
|
-
import {
|
|
4
|
-
CORE_SKILLS,
|
|
5
|
-
SKILL_CATEGORIES,
|
|
6
|
-
DOMAIN_OPTIONS,
|
|
7
|
-
PRINCIPLE_CATEGORIES,
|
|
8
|
-
} from '../prompts/playbook.js';
|
|
9
|
-
|
|
10
|
-
const allDomainValues = DOMAIN_OPTIONS.map((d) => d.value);
|
|
11
|
-
const allPrincipleValues = PRINCIPLE_CATEGORIES.flatMap((c) => c.options.map((o) => o.value));
|
|
12
|
-
const allOptionalSkillValues = SKILL_CATEGORIES.flatMap((c) => c.options.map((o) => o.value));
|
|
13
|
-
|
|
14
|
-
describe('Archetypes', () => {
|
|
15
|
-
it('should have unique values', () => {
|
|
16
|
-
const values = ARCHETYPES.map((a) => a.value);
|
|
17
|
-
expect(new Set(values).size).toBe(values.length);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('should all have tier field', () => {
|
|
21
|
-
for (const a of ARCHETYPES) {
|
|
22
|
-
expect(a.tier).toMatch(/^(free|premium)$/);
|
|
23
|
-
}
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('should have at least 9 archetypes', () => {
|
|
27
|
-
expect(ARCHETYPES.length).toBeGreaterThanOrEqual(9);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('should reference only valid domains', () => {
|
|
31
|
-
for (const a of ARCHETYPES) {
|
|
32
|
-
for (const d of a.defaults.domains) {
|
|
33
|
-
expect(allDomainValues).toContain(d);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('should reference only valid principles', () => {
|
|
39
|
-
for (const a of ARCHETYPES) {
|
|
40
|
-
for (const pr of a.defaults.principles) {
|
|
41
|
-
expect(allPrincipleValues).toContain(pr);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('should not include core skills in archetype skills', () => {
|
|
47
|
-
const coreSet = new Set<string>(CORE_SKILLS);
|
|
48
|
-
for (const a of ARCHETYPES) {
|
|
49
|
-
for (const s of a.defaults.skills) {
|
|
50
|
-
expect(coreSet.has(s)).toBe(false);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('should reference only valid optional skills', () => {
|
|
56
|
-
for (const a of ARCHETYPES) {
|
|
57
|
-
for (const s of a.defaults.skills) {
|
|
58
|
-
expect(allOptionalSkillValues).toContain(s);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('should include Accessibility Guardian', () => {
|
|
64
|
-
expect(ARCHETYPES.find((a) => a.value === 'accessibility-guardian')).toBeDefined();
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('should include Documentation Writer', () => {
|
|
68
|
-
expect(ARCHETYPES.find((a) => a.value === 'documentation-writer')).toBeDefined();
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
describe('Core Skills', () => {
|
|
73
|
-
it('should include writing-plans and executing-plans', () => {
|
|
74
|
-
expect(CORE_SKILLS).toContain('writing-plans');
|
|
75
|
-
expect(CORE_SKILLS).toContain('executing-plans');
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('should not appear in optional skill categories', () => {
|
|
79
|
-
const coreSet = new Set<string>(CORE_SKILLS);
|
|
80
|
-
for (const s of allOptionalSkillValues) {
|
|
81
|
-
expect(coreSet.has(s)).toBe(false);
|
|
82
|
-
}
|
|
83
|
-
});
|
|
84
|
-
});
|
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import { previewScaffold, scaffold } from '@soleri/forge/lib';
|
|
6
|
-
import type { AgentConfig } from '@soleri/forge/lib';
|
|
7
|
-
import { installPack } from '../hook-packs/installer.js';
|
|
8
|
-
|
|
9
|
-
describe('create command', { timeout: 30_000 }, () => {
|
|
10
|
-
let tempDir: string;
|
|
11
|
-
|
|
12
|
-
const testConfig: AgentConfig = {
|
|
13
|
-
id: 'test-agent',
|
|
14
|
-
name: 'TestAgent',
|
|
15
|
-
role: 'A test agent',
|
|
16
|
-
description: 'This agent is used for testing the CLI create command.',
|
|
17
|
-
domains: ['testing', 'quality'],
|
|
18
|
-
principles: ['Test everything', 'Quality first'],
|
|
19
|
-
greeting: 'Hello! I am TestAgent, here to help with testing.',
|
|
20
|
-
outputDir: '',
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
beforeEach(() => {
|
|
24
|
-
tempDir = join(tmpdir(), `cli-create-test-${Date.now()}`);
|
|
25
|
-
mkdirSync(tempDir, { recursive: true });
|
|
26
|
-
testConfig.outputDir = tempDir;
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
afterEach(() => {
|
|
30
|
-
rmSync(tempDir, { recursive: true, force: true });
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('should preview scaffold without creating files', () => {
|
|
34
|
-
const preview = previewScaffold(testConfig);
|
|
35
|
-
|
|
36
|
-
expect(preview.agentDir).toBe(join(tempDir, 'test-agent'));
|
|
37
|
-
expect(preview.persona.name).toBe('TestAgent');
|
|
38
|
-
expect(preview.domains).toEqual(['testing', 'quality']);
|
|
39
|
-
expect(preview.files.length).toBeGreaterThan(10);
|
|
40
|
-
expect(existsSync(preview.agentDir)).toBe(false);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('should scaffold agent successfully', () => {
|
|
44
|
-
const result = scaffold(testConfig);
|
|
45
|
-
|
|
46
|
-
expect(result.success).toBe(true);
|
|
47
|
-
expect(result.agentDir).toBe(join(tempDir, 'test-agent'));
|
|
48
|
-
expect(result.filesCreated.length).toBeGreaterThan(10);
|
|
49
|
-
expect(existsSync(join(tempDir, 'test-agent', 'package.json'))).toBe(true);
|
|
50
|
-
expect(existsSync(join(tempDir, 'test-agent', 'src', 'index.ts'))).toBe(true);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('should fail if directory already exists', () => {
|
|
54
|
-
scaffold(testConfig);
|
|
55
|
-
const result = scaffold(testConfig);
|
|
56
|
-
|
|
57
|
-
expect(result.success).toBe(false);
|
|
58
|
-
expect(result.summary).toContain('already exists');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('should not create facade files (v5.0 uses runtime factories from @soleri/core)', () => {
|
|
62
|
-
scaffold(testConfig);
|
|
63
|
-
|
|
64
|
-
// v5.0: facades are created at runtime by createDomainFacades() — no generated files
|
|
65
|
-
expect(existsSync(join(tempDir, 'test-agent', 'src', 'facades'))).toBe(false);
|
|
66
|
-
|
|
67
|
-
// Entry point should reference createDomainFacades
|
|
68
|
-
const entry = readFileSync(join(tempDir, 'test-agent', 'src', 'index.ts'), 'utf-8');
|
|
69
|
-
expect(entry).toContain('createDomainFacades');
|
|
70
|
-
expect(entry).toContain('"testing"');
|
|
71
|
-
expect(entry).toContain('"quality"');
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('should create intelligence data files for each domain', () => {
|
|
75
|
-
scaffold(testConfig);
|
|
76
|
-
|
|
77
|
-
const testingBundle = JSON.parse(
|
|
78
|
-
readFileSync(
|
|
79
|
-
join(tempDir, 'test-agent', 'src', 'intelligence', 'data', 'testing.json'),
|
|
80
|
-
'utf-8',
|
|
81
|
-
),
|
|
82
|
-
);
|
|
83
|
-
expect(testingBundle.domain).toBe('testing');
|
|
84
|
-
expect(testingBundle.entries.length).toBeGreaterThanOrEqual(0);
|
|
85
|
-
if (testingBundle.entries.length > 0) {
|
|
86
|
-
expect(testingBundle.entries[0].id).toBe('testing-seed');
|
|
87
|
-
expect(testingBundle.entries[0].tags).toContain('seed');
|
|
88
|
-
}
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('should read config from file for non-interactive mode', () => {
|
|
92
|
-
const configPath = join(tempDir, 'agent.json');
|
|
93
|
-
writeFileSync(configPath, JSON.stringify(testConfig), 'utf-8');
|
|
94
|
-
|
|
95
|
-
const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
96
|
-
expect(raw.id).toBe('test-agent');
|
|
97
|
-
expect(raw.domains).toEqual(['testing', 'quality']);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
// ─── Hook pack integration tests ──────────────────────────────
|
|
101
|
-
|
|
102
|
-
it('should create .claude/ directory when hookPacks specified', () => {
|
|
103
|
-
const configWithHooks: AgentConfig = {
|
|
104
|
-
...testConfig,
|
|
105
|
-
hookPacks: ['typescript-safety'],
|
|
106
|
-
};
|
|
107
|
-
const result = scaffold(configWithHooks);
|
|
108
|
-
|
|
109
|
-
expect(result.success).toBe(true);
|
|
110
|
-
expect(existsSync(join(tempDir, 'test-agent', '.claude'))).toBe(true);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('should install hookify files to agent .claude/ via installPack', () => {
|
|
114
|
-
const configWithHooks: AgentConfig = {
|
|
115
|
-
...testConfig,
|
|
116
|
-
hookPacks: ['typescript-safety'],
|
|
117
|
-
};
|
|
118
|
-
const result = scaffold(configWithHooks);
|
|
119
|
-
expect(result.success).toBe(true);
|
|
120
|
-
|
|
121
|
-
// Simulate what create.ts does: install packs into agent dir
|
|
122
|
-
const { installed } = installPack('typescript-safety', { projectDir: result.agentDir });
|
|
123
|
-
expect(installed.length).toBeGreaterThan(0);
|
|
124
|
-
|
|
125
|
-
// Verify hookify files exist in agent .claude/
|
|
126
|
-
const claudeDir = join(result.agentDir, '.claude');
|
|
127
|
-
const hookFiles = readdirSync(claudeDir).filter(
|
|
128
|
-
(f) => f.startsWith('hookify.') && f.endsWith('.local.md'),
|
|
129
|
-
);
|
|
130
|
-
expect(hookFiles.length).toBeGreaterThan(0);
|
|
131
|
-
expect(hookFiles.some((f) => f.includes('no-any-types'))).toBe(true);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('should not create .claude/ when hookPacks is empty or undefined', () => {
|
|
135
|
-
const result = scaffold(testConfig);
|
|
136
|
-
|
|
137
|
-
expect(result.success).toBe(true);
|
|
138
|
-
expect(existsSync(join(tempDir, 'test-agent', '.claude'))).toBe(false);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('should include hook packs in preview when hookPacks specified', () => {
|
|
142
|
-
const configWithHooks: AgentConfig = {
|
|
143
|
-
...testConfig,
|
|
144
|
-
hookPacks: ['typescript-safety'],
|
|
145
|
-
};
|
|
146
|
-
const preview = previewScaffold(configWithHooks);
|
|
147
|
-
|
|
148
|
-
const hookEntry = preview.files.find((f) => f.path === '.claude/');
|
|
149
|
-
expect(hookEntry).toBeDefined();
|
|
150
|
-
expect(hookEntry!.description).toContain('typescript-safety');
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('should include Hook Packs section in CLAUDE.md when hookPacks specified', () => {
|
|
154
|
-
const configWithHooks: AgentConfig = {
|
|
155
|
-
...testConfig,
|
|
156
|
-
hookPacks: ['typescript-safety'],
|
|
157
|
-
};
|
|
158
|
-
scaffold(configWithHooks);
|
|
159
|
-
|
|
160
|
-
const claudeMd = readFileSync(
|
|
161
|
-
join(tempDir, 'test-agent', 'src', 'activation', 'claude-md-content.ts'),
|
|
162
|
-
'utf-8',
|
|
163
|
-
);
|
|
164
|
-
expect(claudeMd).toContain('Hook Packs');
|
|
165
|
-
expect(claudeMd).toContain('typescript-safety');
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it('should not include Hook Packs section in CLAUDE.md when hookPacks undefined', () => {
|
|
169
|
-
scaffold(testConfig);
|
|
170
|
-
|
|
171
|
-
const claudeMd = readFileSync(
|
|
172
|
-
join(tempDir, 'test-agent', 'src', 'activation', 'claude-md-content.ts'),
|
|
173
|
-
'utf-8',
|
|
174
|
-
);
|
|
175
|
-
expect(claudeMd).not.toContain('Hook Packs');
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it('should include hook copy logic in setup.sh when hookPacks specified', () => {
|
|
179
|
-
const configWithHooks: AgentConfig = {
|
|
180
|
-
...testConfig,
|
|
181
|
-
hookPacks: ['typescript-safety'],
|
|
182
|
-
};
|
|
183
|
-
scaffold(configWithHooks);
|
|
184
|
-
|
|
185
|
-
const setupSh = readFileSync(join(tempDir, 'test-agent', 'scripts', 'setup.sh'), 'utf-8');
|
|
186
|
-
expect(setupSh).toContain('Installing hook packs');
|
|
187
|
-
expect(setupSh).toContain('hookify.');
|
|
188
|
-
expect(setupSh).toContain('GLOBAL_CLAUDE_DIR');
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it('should not include hook copy logic in setup.sh when hookPacks undefined', () => {
|
|
192
|
-
scaffold(testConfig);
|
|
193
|
-
|
|
194
|
-
const setupSh = readFileSync(join(tempDir, 'test-agent', 'scripts', 'setup.sh'), 'utf-8');
|
|
195
|
-
expect(setupSh).not.toContain('Installing hook packs');
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it('should mention hook packs in scaffold summary', () => {
|
|
199
|
-
const configWithHooks: AgentConfig = {
|
|
200
|
-
...testConfig,
|
|
201
|
-
hookPacks: ['typescript-safety', 'a11y'],
|
|
202
|
-
};
|
|
203
|
-
const result = scaffold(configWithHooks);
|
|
204
|
-
|
|
205
|
-
expect(result.summary).toContain('2 hook pack(s) bundled in .claude/');
|
|
206
|
-
});
|
|
207
|
-
});
|
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Anti-Deletion Staging Hook for Claude Code (Soleri Hook Pack: yolo-safety)
|
|
3
|
-
# PreToolUse -> Bash: intercepts rm, rmdir, mv (of project dirs), git clean, reset --hard
|
|
4
|
-
# Copies target files to ~/.soleri/staging/<timestamp>/ then blocks the command.
|
|
5
|
-
#
|
|
6
|
-
# Catastrophic commands (rm -rf /, rm -rf ~) should stay in deny rules —
|
|
7
|
-
# this hook handles targeted deletes only.
|
|
8
|
-
#
|
|
9
|
-
# Dependencies: jq (required), perl (optional, for heredoc stripping)
|
|
10
|
-
|
|
11
|
-
set -euo pipefail
|
|
12
|
-
|
|
13
|
-
STAGING_ROOT="$HOME/.soleri/staging"
|
|
14
|
-
PROJECTS_DIR="$HOME/projects"
|
|
15
|
-
INPUT=$(cat)
|
|
16
|
-
|
|
17
|
-
# Extract the command from stdin JSON
|
|
18
|
-
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
19
|
-
|
|
20
|
-
# No command found — let it through
|
|
21
|
-
if [ -z "$CMD" ]; then
|
|
22
|
-
exit 0
|
|
23
|
-
fi
|
|
24
|
-
|
|
25
|
-
# --- Strip heredocs and quoted strings to avoid false positives ---
|
|
26
|
-
# Commands like: gh issue comment --body "$(cat <<'EOF' ... rmdir ... EOF)"
|
|
27
|
-
# contain destructive keywords in text, not as actual commands.
|
|
28
|
-
|
|
29
|
-
# Remove heredoc blocks: <<'EOF'...EOF and <<EOF...EOF (multiline)
|
|
30
|
-
STRIPPED=$(echo "$CMD" | perl -0777 -pe "s/<<'?\\w+'?.*?^\\w+$//gms" 2>/dev/null || echo "$CMD")
|
|
31
|
-
# Remove double-quoted strings (greedy but good enough for this check)
|
|
32
|
-
STRIPPED=$(echo "$STRIPPED" | sed -E 's/"[^"]*"//g' 2>/dev/null || echo "$STRIPPED")
|
|
33
|
-
# Remove single-quoted strings
|
|
34
|
-
STRIPPED=$(echo "$STRIPPED" | sed -E "s/'[^']*'//g" 2>/dev/null || echo "$STRIPPED")
|
|
35
|
-
|
|
36
|
-
# --- Detect destructive commands (on stripped command only) ---
|
|
37
|
-
|
|
38
|
-
IS_RM=false
|
|
39
|
-
IS_RMDIR=false
|
|
40
|
-
IS_MV_PROJECT=false
|
|
41
|
-
IS_GIT_CLEAN=false
|
|
42
|
-
IS_RESET_HARD=false
|
|
43
|
-
IS_GIT_CHECKOUT_DOT=false
|
|
44
|
-
IS_GIT_RESTORE_DOT=false
|
|
45
|
-
|
|
46
|
-
# Check for rm commands (but not git rm which is safe — it stages, doesn't destroy)
|
|
47
|
-
if echo "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)rm\s'; then
|
|
48
|
-
if ! echo "$STRIPPED" | grep -qE '(^|\s)git\s+rm\s'; then
|
|
49
|
-
IS_RM=true
|
|
50
|
-
fi
|
|
51
|
-
fi
|
|
52
|
-
|
|
53
|
-
# Check for rmdir commands
|
|
54
|
-
if echo "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)rmdir\s'; then
|
|
55
|
-
IS_RMDIR=true
|
|
56
|
-
fi
|
|
57
|
-
|
|
58
|
-
# Check for mv commands that move project directories or git repos
|
|
59
|
-
if echo "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)mv\s'; then
|
|
60
|
-
MV_SOURCES=$(echo "$STRIPPED" | sed -E 's/^.*\bmv\s+//' | sed -E 's/-(f|i|n|v)\s+//g')
|
|
61
|
-
if echo "$MV_SOURCES" | grep -qE "(~/projects|$HOME/projects|\\\$HOME/projects|\\.git)"; then
|
|
62
|
-
IS_MV_PROJECT=true
|
|
63
|
-
fi
|
|
64
|
-
fi
|
|
65
|
-
|
|
66
|
-
# Check for git clean
|
|
67
|
-
if echo "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)git\s+clean\b'; then
|
|
68
|
-
IS_GIT_CLEAN=true
|
|
69
|
-
fi
|
|
70
|
-
|
|
71
|
-
# Check for git reset --hard
|
|
72
|
-
if echo "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)git\s+reset\s+--hard'; then
|
|
73
|
-
IS_RESET_HARD=true
|
|
74
|
-
fi
|
|
75
|
-
|
|
76
|
-
# Check for git checkout -- . (restores all files, discards changes)
|
|
77
|
-
if echo "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)git\s+checkout\s+--\s+\.'; then
|
|
78
|
-
IS_GIT_CHECKOUT_DOT=true
|
|
79
|
-
fi
|
|
80
|
-
|
|
81
|
-
# Check for git restore . (restores all files, discards changes)
|
|
82
|
-
if echo "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)git\s+restore\s+\.'; then
|
|
83
|
-
IS_GIT_RESTORE_DOT=true
|
|
84
|
-
fi
|
|
85
|
-
|
|
86
|
-
# Not a destructive command — let it through
|
|
87
|
-
if [ "$IS_RM" = false ] && [ "$IS_RMDIR" = false ] && [ "$IS_MV_PROJECT" = false ] && \
|
|
88
|
-
[ "$IS_GIT_CLEAN" = false ] && [ "$IS_RESET_HARD" = false ] && \
|
|
89
|
-
[ "$IS_GIT_CHECKOUT_DOT" = false ] && [ "$IS_GIT_RESTORE_DOT" = false ]; then
|
|
90
|
-
exit 0
|
|
91
|
-
fi
|
|
92
|
-
|
|
93
|
-
# --- Handle git clean (block outright) ---
|
|
94
|
-
|
|
95
|
-
if [ "$IS_GIT_CLEAN" = true ]; then
|
|
96
|
-
jq -n '{
|
|
97
|
-
continue: false,
|
|
98
|
-
stopReason: "BLOCKED: git clean would remove untracked files. Use git stash --include-untracked to save them first, or ask the user to run git clean manually."
|
|
99
|
-
}'
|
|
100
|
-
exit 0
|
|
101
|
-
fi
|
|
102
|
-
|
|
103
|
-
# --- Handle git reset --hard (block outright) ---
|
|
104
|
-
|
|
105
|
-
if [ "$IS_RESET_HARD" = true ]; then
|
|
106
|
-
jq -n '{
|
|
107
|
-
continue: false,
|
|
108
|
-
stopReason: "BLOCKED: git reset --hard would discard uncommitted changes. Use git stash to save them first, or ask the user to run this manually."
|
|
109
|
-
}'
|
|
110
|
-
exit 0
|
|
111
|
-
fi
|
|
112
|
-
|
|
113
|
-
# --- Handle git checkout -- . (block outright) ---
|
|
114
|
-
|
|
115
|
-
if [ "$IS_GIT_CHECKOUT_DOT" = true ]; then
|
|
116
|
-
jq -n '{
|
|
117
|
-
continue: false,
|
|
118
|
-
stopReason: "BLOCKED: git checkout -- . would discard all uncommitted changes. Use git stash to save them first, or ask the user to run this manually."
|
|
119
|
-
}'
|
|
120
|
-
exit 0
|
|
121
|
-
fi
|
|
122
|
-
|
|
123
|
-
# --- Handle git restore . (block outright) ---
|
|
124
|
-
|
|
125
|
-
if [ "$IS_GIT_RESTORE_DOT" = true ]; then
|
|
126
|
-
jq -n '{
|
|
127
|
-
continue: false,
|
|
128
|
-
stopReason: "BLOCKED: git restore . would discard all uncommitted changes. Use git stash to save them first, or ask the user to run this manually."
|
|
129
|
-
}'
|
|
130
|
-
exit 0
|
|
131
|
-
fi
|
|
132
|
-
|
|
133
|
-
# --- Handle mv of project directories (block outright) ---
|
|
134
|
-
|
|
135
|
-
if [ "$IS_MV_PROJECT" = true ]; then
|
|
136
|
-
jq -n '{
|
|
137
|
-
continue: false,
|
|
138
|
-
stopReason: "BLOCKED: mv of a project directory or git repo detected. Moving project directories can cause data loss if the operation fails midway. Ask the user to run this manually, or use cp + verify + rm instead."
|
|
139
|
-
}'
|
|
140
|
-
exit 0
|
|
141
|
-
fi
|
|
142
|
-
|
|
143
|
-
# --- Handle rmdir (block outright) ---
|
|
144
|
-
|
|
145
|
-
if [ "$IS_RMDIR" = true ]; then
|
|
146
|
-
jq -n '{
|
|
147
|
-
continue: false,
|
|
148
|
-
stopReason: "BLOCKED: rmdir detected. Removing directories can break project structure. Ask the user to confirm this operation manually."
|
|
149
|
-
}'
|
|
150
|
-
exit 0
|
|
151
|
-
fi
|
|
152
|
-
|
|
153
|
-
# --- Handle rm commands — copy to staging, then block ---
|
|
154
|
-
|
|
155
|
-
# Create timestamped staging directory
|
|
156
|
-
TIMESTAMP=$(date +%Y-%m-%d_%H%M%S)
|
|
157
|
-
STAGE_DIR="$STAGING_ROOT/$TIMESTAMP"
|
|
158
|
-
|
|
159
|
-
# Extract file paths from the rm command
|
|
160
|
-
# Strip rm and its flags, keeping only the file arguments
|
|
161
|
-
FILES=$(echo "$CMD" | sed -E 's/^.*\brm\s+//' | sed -E 's/-(r|f|rf|fr|v|i|rv|fv|rfv|frv)\s+//g' | tr ' ' '\n' | grep -v '^-' | grep -v '^$')
|
|
162
|
-
|
|
163
|
-
if [ -z "$FILES" ]; then
|
|
164
|
-
jq -n '{
|
|
165
|
-
continue: false,
|
|
166
|
-
stopReason: "BLOCKED: rm command detected but could not parse file targets. Please specify files explicitly."
|
|
167
|
-
}'
|
|
168
|
-
exit 0
|
|
169
|
-
fi
|
|
170
|
-
|
|
171
|
-
STAGED=()
|
|
172
|
-
MISSING=()
|
|
173
|
-
|
|
174
|
-
mkdir -p "$STAGE_DIR"
|
|
175
|
-
|
|
176
|
-
while IFS= read -r filepath; do
|
|
177
|
-
# Expand path (handle ~, relative paths)
|
|
178
|
-
expanded=$(eval echo "$filepath" 2>/dev/null || echo "$filepath")
|
|
179
|
-
|
|
180
|
-
if [ -e "$expanded" ]; then
|
|
181
|
-
# Preserve directory structure in staging
|
|
182
|
-
target_dir="$STAGE_DIR/$(dirname "$expanded")"
|
|
183
|
-
mkdir -p "$target_dir"
|
|
184
|
-
# COPY instead of MOVE — originals stay intact, staging is a backup
|
|
185
|
-
if [ -d "$expanded" ]; then
|
|
186
|
-
cp -R "$expanded" "$target_dir/" 2>/dev/null && STAGED+=("$expanded") || MISSING+=("$expanded")
|
|
187
|
-
else
|
|
188
|
-
cp "$expanded" "$target_dir/" 2>/dev/null && STAGED+=("$expanded") || MISSING+=("$expanded")
|
|
189
|
-
fi
|
|
190
|
-
else
|
|
191
|
-
MISSING+=("$expanded")
|
|
192
|
-
fi
|
|
193
|
-
done <<< "$FILES"
|
|
194
|
-
|
|
195
|
-
# Build response
|
|
196
|
-
STAGED_COUNT=${#STAGED[@]}
|
|
197
|
-
MISSING_COUNT=${#MISSING[@]}
|
|
198
|
-
|
|
199
|
-
if [ "$STAGED_COUNT" -eq 0 ] && [ "$MISSING_COUNT" -gt 0 ]; then
|
|
200
|
-
# All files were missing — let the rm fail naturally
|
|
201
|
-
rmdir "$STAGE_DIR" 2>/dev/null || true
|
|
202
|
-
exit 0
|
|
203
|
-
fi
|
|
204
|
-
|
|
205
|
-
STAGED_LIST=$(printf '%s, ' "${STAGED[@]}" | sed 's/, $//')
|
|
206
|
-
|
|
207
|
-
jq -n \
|
|
208
|
-
--arg staged "$STAGED_LIST" \
|
|
209
|
-
--arg dir "$STAGE_DIR" \
|
|
210
|
-
--argjson count "$STAGED_COUNT" \
|
|
211
|
-
'{
|
|
212
|
-
continue: false,
|
|
213
|
-
stopReason: ("BLOCKED & BACKED UP: " + ($count | tostring) + " item(s) copied to " + $dir + " — files: " + $staged + ". The originals are untouched. To proceed with deletion, ask the user to run the rm command manually.")
|
|
214
|
-
}'
|