servicemap 0.1.0 → 0.1.5
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 +49 -2
- package/bin/servicemap.mjs +291 -6
- package/package.json +2 -2
- package/servicemap/SKILL.md +12 -7
- package/servicemap/scripts/generate-page.mjs +37 -0
- package/servicemap/scripts/scan-project.mjs +15 -1
package/README.md
CHANGED
|
@@ -6,12 +6,43 @@ The skill scans a software project for external services, resolves service logos
|
|
|
6
6
|
|
|
7
7
|
## Terminal Use
|
|
8
8
|
|
|
9
|
-
The npm package name `servicemap` is available, so users can run the clean command:
|
|
9
|
+
The npm package name `servicemap` is available, so users can run the clean command and choose where to install it:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npx servicemap
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
That opens the interactive install survey.
|
|
16
|
+
It lets users choose compatible AI coding agents and model environments.
|
|
17
|
+
|
|
18
|
+
To install the bundled AI skill so `/servicemap` appears in compatible coding agents:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx servicemap install-skill
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
To use an interactive Caveman-style terminal survey:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx servicemap install-skill --survey
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
That installs to the Codex-style user skill folder, matching the way Caveman installs a local skill folder.
|
|
31
|
+
To install for several agents on the same computer:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx servicemap install-skill --agent all
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The Caveman-style command shape also works:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx servicemap skills install --agent claude
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Agents with known local skill folders get the real `servicemap` skill installed.
|
|
44
|
+
Other selected AI tools get setup notes under `~/.servicemap/model-guides`.
|
|
45
|
+
|
|
15
46
|
After a global install, the command is:
|
|
16
47
|
|
|
17
48
|
```bash
|
|
@@ -27,10 +58,22 @@ npm install -g servicemap
|
|
|
27
58
|
CLI options:
|
|
28
59
|
|
|
29
60
|
```bash
|
|
61
|
+
servicemap
|
|
62
|
+
servicemap generate --root <project-path>
|
|
30
63
|
servicemap --root <project-path>
|
|
64
|
+
servicemap generate --root <project-path> --allow-emails founder@example.com,ops@example.com
|
|
65
|
+
servicemap generate --root <project-path> --allow-email founder@example.com --allow-email ops@example.com
|
|
31
66
|
servicemap --mode page
|
|
32
67
|
servicemap --mode desktop
|
|
33
68
|
servicemap --dry-run
|
|
69
|
+
servicemap install-skill
|
|
70
|
+
servicemap install-skill --survey
|
|
71
|
+
servicemap install-skill --agent codex
|
|
72
|
+
servicemap install-skill --agent claude
|
|
73
|
+
servicemap install-skill --agent grok
|
|
74
|
+
servicemap install-skill --agent all
|
|
75
|
+
servicemap install-skill --targets codex,gemini,cursor
|
|
76
|
+
servicemap skills install --agent codex
|
|
34
77
|
```
|
|
35
78
|
|
|
36
79
|
## Contents
|
|
@@ -45,7 +88,11 @@ servicemap --dry-run
|
|
|
45
88
|
## Validation
|
|
46
89
|
|
|
47
90
|
```bash
|
|
48
|
-
node bin/servicemap.mjs --root demo-project --dry-run
|
|
91
|
+
node bin/servicemap.mjs generate --root demo-project --allow-emails founder@example.com,ops@example.com --dry-run
|
|
49
92
|
node bin/servicemap.mjs --root no-website-demo --dry-run
|
|
93
|
+
node bin/servicemap.mjs --dry-run
|
|
94
|
+
node bin/servicemap.mjs install-skill --agent all --dry-run
|
|
95
|
+
node bin/servicemap.mjs install-skill --targets codex,gemini,cursor --dry-run
|
|
96
|
+
node bin/servicemap.mjs skills install --agent claude --dry-run
|
|
50
97
|
python3 /Users/dougie/.codex/skills/.system/skill-creator/scripts/quick_validate.py servicemap
|
|
51
98
|
```
|
package/bin/servicemap.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import readline from 'node:readline';
|
|
5
8
|
import { fileURLToPath } from 'node:url';
|
|
6
9
|
|
|
7
10
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -9,18 +12,119 @@ const scanScript = resolve(packageRoot, 'servicemap/scripts/scan-project.mjs');
|
|
|
9
12
|
const pageScript = resolve(packageRoot, 'servicemap/scripts/generate-page.mjs');
|
|
10
13
|
const desktopScript = resolve(packageRoot, 'servicemap/scripts/generate-desktop-app.mjs');
|
|
11
14
|
|
|
15
|
+
const targetCatalog = [
|
|
16
|
+
{ id: 'codex', label: 'OpenAI (Codex, ChatGPT, GPT models)', kind: 'skill', root: () => resolve(homedir(), '.agents/skills'), recommended: true },
|
|
17
|
+
{ id: 'claude', label: 'Anthropic (Claude Code, Claude models)', kind: 'skill', root: () => resolve(homedir(), '.claude/skills'), recommended: true },
|
|
18
|
+
{ id: 'grok', label: 'xAI (Grok)', kind: 'skill', root: () => resolve(homedir(), '.grok/skills'), recommended: true },
|
|
19
|
+
{ id: 'gemini', label: 'Google (Gemini, Vertex AI)', kind: 'guide' },
|
|
20
|
+
{ id: 'cursor', label: 'Cursor', kind: 'guide' },
|
|
21
|
+
{ id: 'windsurf', label: 'Windsurf', kind: 'guide' },
|
|
22
|
+
{ id: 'copilot', label: 'GitHub Copilot', kind: 'guide' },
|
|
23
|
+
{ id: 'vscode-chat', label: 'VS Code Chat', kind: 'guide' },
|
|
24
|
+
{ id: 'continue', label: 'Continue.dev', kind: 'guide' },
|
|
25
|
+
{ id: 'aider', label: 'Aider', kind: 'guide' },
|
|
26
|
+
{ id: 'cline', label: 'Cline', kind: 'guide' },
|
|
27
|
+
{ id: 'roo-code', label: 'Roo Code', kind: 'guide' },
|
|
28
|
+
{ id: 'zed-ai', label: 'Zed AI', kind: 'guide' },
|
|
29
|
+
{ id: 'replit-agent', label: 'Replit Agent', kind: 'guide' },
|
|
30
|
+
{ id: 'lovable', label: 'Lovable', kind: 'guide' },
|
|
31
|
+
{ id: 'bolt', label: 'Bolt', kind: 'guide' },
|
|
32
|
+
{ id: 'v0', label: 'v0', kind: 'guide' },
|
|
33
|
+
{ id: 'perplexity', label: 'Perplexity', kind: 'guide' },
|
|
34
|
+
{ id: 'mistral', label: 'Mistral', kind: 'guide' },
|
|
35
|
+
{ id: 'deepseek', label: 'DeepSeek', kind: 'guide' },
|
|
36
|
+
{ id: 'qwen', label: 'Qwen', kind: 'guide' },
|
|
37
|
+
{ id: 'meta', label: 'Meta (Llama)', kind: 'guide' },
|
|
38
|
+
{ id: 'local-models', label: 'Local models (Ollama, LM Studio)', kind: 'guide' },
|
|
39
|
+
{ id: 'openrouter', label: 'OpenRouter', kind: 'guide' },
|
|
40
|
+
{ id: 'bedrock', label: 'Amazon Bedrock', kind: 'guide' },
|
|
41
|
+
{ id: 'azure-openai', label: 'Azure OpenAI', kind: 'guide' },
|
|
42
|
+
{ id: 'poe', label: 'Poe', kind: 'guide' },
|
|
43
|
+
{ id: 'you-com', label: 'You.com', kind: 'guide' },
|
|
44
|
+
{ id: 'phind', label: 'Phind', kind: 'guide' },
|
|
45
|
+
{ id: 'sourcegraph-cody', label: 'Sourcegraph Cody', kind: 'guide' },
|
|
46
|
+
{ id: 'tabnine', label: 'Tabnine', kind: 'guide' },
|
|
47
|
+
{ id: 'codeium', label: 'Codeium', kind: 'guide' },
|
|
48
|
+
{ id: 'jetbrains-ai', label: 'JetBrains AI', kind: 'guide' },
|
|
49
|
+
{ id: 'custom', label: 'Other custom model or agent', kind: 'guide' },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const targetAliases = new Map([
|
|
53
|
+
['agents', 'codex'],
|
|
54
|
+
['chatgpt', 'codex'],
|
|
55
|
+
['openai', 'codex'],
|
|
56
|
+
['openai-api', 'codex'],
|
|
57
|
+
['gpt-6-astra', 'codex'],
|
|
58
|
+
['gpt-6-sol', 'codex'],
|
|
59
|
+
['gpt-6-luna', 'codex'],
|
|
60
|
+
['gpt-5.6-sol', 'codex'],
|
|
61
|
+
['gpt-5.6-terra', 'codex'],
|
|
62
|
+
['gpt-5.6-luna', 'codex'],
|
|
63
|
+
['anthropic', 'claude'],
|
|
64
|
+
['anthropic-api', 'claude'],
|
|
65
|
+
['claude-opus', 'claude'],
|
|
66
|
+
['claude-sonnet', 'claude'],
|
|
67
|
+
['claude-haiku', 'claude'],
|
|
68
|
+
['google', 'gemini'],
|
|
69
|
+
['gemini-pro', 'gemini'],
|
|
70
|
+
['gemini-flash', 'gemini'],
|
|
71
|
+
['gemini-api', 'gemini'],
|
|
72
|
+
['vertex-ai', 'gemini'],
|
|
73
|
+
['llama', 'meta'],
|
|
74
|
+
['ollama', 'local-models'],
|
|
75
|
+
['lm-studio', 'local-models'],
|
|
76
|
+
]);
|
|
77
|
+
|
|
12
78
|
function parseArgs() {
|
|
13
79
|
const args = process.argv.slice(2);
|
|
14
80
|
const options = {
|
|
81
|
+
command: 'generate',
|
|
15
82
|
root: process.cwd(),
|
|
16
83
|
mode: 'auto',
|
|
17
84
|
dryRun: false,
|
|
85
|
+
agent: 'codex',
|
|
86
|
+
targets: null,
|
|
87
|
+
skillsDir: null,
|
|
88
|
+
allowedEmails: [],
|
|
89
|
+
invokedWithoutArgs: args.length === 0,
|
|
90
|
+
explicitCommand: false,
|
|
91
|
+
generationRequested: false,
|
|
18
92
|
};
|
|
19
93
|
|
|
94
|
+
if (args[0] === 'skills' && args[1] === 'install') {
|
|
95
|
+
options.command = 'install-skill';
|
|
96
|
+
args.splice(0, 2);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (args[0] && !args[0].startsWith('-')) {
|
|
100
|
+
options.command = args.shift();
|
|
101
|
+
options.explicitCommand = true;
|
|
102
|
+
}
|
|
103
|
+
|
|
20
104
|
for (let index = 0; index < args.length; index += 1) {
|
|
21
105
|
const arg = args[index];
|
|
22
|
-
if (arg === '--root')
|
|
23
|
-
|
|
106
|
+
if (arg === '--root') {
|
|
107
|
+
options.root = args[++index] ?? options.root;
|
|
108
|
+
options.generationRequested = true;
|
|
109
|
+
}
|
|
110
|
+
if (arg === '--mode') {
|
|
111
|
+
options.mode = args[++index] ?? options.mode;
|
|
112
|
+
options.generationRequested = true;
|
|
113
|
+
}
|
|
114
|
+
if (arg === '--agent') options.agent = args[++index] ?? options.agent;
|
|
115
|
+
if (arg === '--targets') options.targets = (args[++index] ?? '').split(',').map((target) => target.trim()).filter(Boolean);
|
|
116
|
+
if (arg === '--allow-email') {
|
|
117
|
+
options.allowedEmails.push(args[++index] ?? '');
|
|
118
|
+
options.generationRequested = true;
|
|
119
|
+
}
|
|
120
|
+
if (arg === '--allow-emails') {
|
|
121
|
+
options.allowedEmails.push(...(args[++index] ?? '').split(','));
|
|
122
|
+
options.generationRequested = true;
|
|
123
|
+
}
|
|
124
|
+
if (arg === '--skills-dir') options.skillsDir = resolve(args[++index] ?? '.');
|
|
125
|
+
if (arg === '--user') options.user = true;
|
|
126
|
+
if (arg === '--survey') options.survey = true;
|
|
127
|
+
if (arg === '--yes' || arg === '-y') options.yes = true;
|
|
24
128
|
if (arg === '--dry-run') options.dryRun = true;
|
|
25
129
|
if (arg === '--help' || arg === '-h') options.help = true;
|
|
26
130
|
}
|
|
@@ -33,11 +137,21 @@ function help() {
|
|
|
33
137
|
console.log(`Servicemap
|
|
34
138
|
|
|
35
139
|
Usage:
|
|
36
|
-
servicemap
|
|
140
|
+
servicemap
|
|
141
|
+
servicemap generate [--root <project>] [--mode auto|page|desktop] [--allow-emails <list>] [--dry-run]
|
|
142
|
+
servicemap --root <project> [--mode auto|page|desktop] [--allow-emails <list>] [--dry-run]
|
|
143
|
+
servicemap install-skill [--survey] [--agent codex|claude|grok|all] [--targets <ids>]
|
|
144
|
+
servicemap skills install [--survey] [--agent codex|claude|grok|all]
|
|
37
145
|
|
|
38
146
|
Examples:
|
|
39
147
|
npx servicemap
|
|
148
|
+
npx servicemap generate --root ../my-app --allow-emails founder@example.com,ops@example.com
|
|
40
149
|
npx servicemap -- --root ../my-app
|
|
150
|
+
npx servicemap install-skill
|
|
151
|
+
npx servicemap install-skill --survey
|
|
152
|
+
npx servicemap install-skill --agent all
|
|
153
|
+
npx servicemap install-skill --targets codex,claude,cursor
|
|
154
|
+
npx servicemap skills install --agent claude
|
|
41
155
|
servicemap --mode desktop
|
|
42
156
|
`);
|
|
43
157
|
}
|
|
@@ -62,6 +176,159 @@ async function readScan(root) {
|
|
|
62
176
|
return JSON.parse(await readFile(scanPath, 'utf8'));
|
|
63
177
|
}
|
|
64
178
|
|
|
179
|
+
function targetsByIds(ids) {
|
|
180
|
+
const byId = new Map(targetCatalog.map((target) => [target.id, target]));
|
|
181
|
+
return ids.map((id) => {
|
|
182
|
+
const target = byId.get(targetAliases.get(id) ?? id);
|
|
183
|
+
if (!target) {
|
|
184
|
+
throw new Error(`Unknown target "${id}". Use --survey to choose from the list.`);
|
|
185
|
+
}
|
|
186
|
+
return target;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function targetsForOptions(options) {
|
|
191
|
+
if (options.targets?.length) return targetsByIds(options.targets);
|
|
192
|
+
if (options.agent === 'all') return targetCatalog.filter((target) => target.kind === 'skill');
|
|
193
|
+
return targetsByIds([options.agent]);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function skillRootsForTargets(options, targets) {
|
|
197
|
+
const skillTargets = targets.filter((target) => target.kind === 'skill');
|
|
198
|
+
if (options.skillsDir && skillTargets.length) return [options.skillsDir];
|
|
199
|
+
return [...new Set(skillTargets.map((target) => target.root()))];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function guideTargets(targets) {
|
|
203
|
+
return targets.filter((target) => target.kind === 'guide');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function renderSurvey(selected, cursor) {
|
|
207
|
+
console.clear();
|
|
208
|
+
console.log('Where should Servicemap be available?');
|
|
209
|
+
console.log('Use ↑/↓, Space to select, Enter to install.\n');
|
|
210
|
+
|
|
211
|
+
targetCatalog.forEach((target, index) => {
|
|
212
|
+
const pointer = index === cursor ? '>' : ' ';
|
|
213
|
+
const checked = selected.has(target.id) ? 'x' : ' ';
|
|
214
|
+
const detail = target.kind === 'skill' ? 'skill folder' : 'setup notes';
|
|
215
|
+
console.log(`${pointer} [${checked}] ${target.label} (${detail})`);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function runTargetSurvey() {
|
|
220
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
221
|
+
return Promise.resolve(targetCatalog.filter((target) => target.recommended));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const selected = new Set(targetCatalog.filter((target) => target.recommended).map((target) => target.id));
|
|
225
|
+
let cursor = 0;
|
|
226
|
+
|
|
227
|
+
return new Promise((resolvePromise) => {
|
|
228
|
+
readline.emitKeypressEvents(process.stdin);
|
|
229
|
+
process.stdin.setRawMode(true);
|
|
230
|
+
renderSurvey(selected, cursor);
|
|
231
|
+
|
|
232
|
+
const onKeypress = (_str, key) => {
|
|
233
|
+
if (key.name === 'up') cursor = (cursor - 1 + targetCatalog.length) % targetCatalog.length;
|
|
234
|
+
if (key.name === 'down') cursor = (cursor + 1) % targetCatalog.length;
|
|
235
|
+
if (key.name === 'space') {
|
|
236
|
+
const id = targetCatalog[cursor].id;
|
|
237
|
+
if (selected.has(id)) selected.delete(id);
|
|
238
|
+
else selected.add(id);
|
|
239
|
+
}
|
|
240
|
+
if (key.name === 'return') {
|
|
241
|
+
process.stdin.setRawMode(false);
|
|
242
|
+
process.stdin.off('keypress', onKeypress);
|
|
243
|
+
console.log('');
|
|
244
|
+
resolvePromise(targetCatalog.filter((target) => selected.has(target.id)));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (key.ctrl && key.name === 'c') {
|
|
248
|
+
process.stdin.setRawMode(false);
|
|
249
|
+
process.stdin.off('keypress', onKeypress);
|
|
250
|
+
process.exit(130);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
renderSurvey(selected, cursor);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
process.stdin.on('keypress', onKeypress);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function guideText(target) {
|
|
261
|
+
return `# Servicemap for ${target.label}
|
|
262
|
+
|
|
263
|
+
These setup notes help ${target.label} use Servicemap when you want it to create a service map for a software project.
|
|
264
|
+
|
|
265
|
+
Invoke: /servicemap
|
|
266
|
+
|
|
267
|
+
Goal:
|
|
268
|
+
Scan the current project for third-party services, resolve service logos from the bundled Servicemap database, and create an /internalservicemap page when a website is present.
|
|
269
|
+
If no website can be safely found, create a desktop app fallback named servicemap-desktop.
|
|
270
|
+
|
|
271
|
+
Recommended local command:
|
|
272
|
+
\`\`\`bash
|
|
273
|
+
npx servicemap --root <project-path>
|
|
274
|
+
\`\`\`
|
|
275
|
+
|
|
276
|
+
If your agent supports local skills, install the real skill folder with:
|
|
277
|
+
\`\`\`bash
|
|
278
|
+
npx servicemap install-skill --agent all
|
|
279
|
+
\`\`\`
|
|
280
|
+
`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function writeGuidePacks(targets, options) {
|
|
284
|
+
const guideRoot = resolve(homedir(), '.servicemap/model-guides');
|
|
285
|
+
for (const target of guideTargets(targets)) {
|
|
286
|
+
const destination = join(guideRoot, target.id);
|
|
287
|
+
if (options.dryRun) {
|
|
288
|
+
console.log(`Would write Servicemap guide for ${target.label} to ${destination}`);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
await mkdir(destination, { recursive: true });
|
|
292
|
+
await cp(resolve(packageRoot, 'servicemap/SKILL.md'), join(destination, 'SKILL.md'));
|
|
293
|
+
await mkdir(join(destination, 'servicemap'), { recursive: true });
|
|
294
|
+
await cp(resolve(packageRoot, 'servicemap'), join(destination, 'servicemap'), { recursive: true });
|
|
295
|
+
await writeFile(join(destination, 'README.md'), guideText(target));
|
|
296
|
+
console.log(`Wrote Servicemap guide for ${target.label} to ${destination}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function installSkill(options) {
|
|
301
|
+
const source = resolve(packageRoot, 'servicemap');
|
|
302
|
+
const selectedTargets = options.survey && !options.yes
|
|
303
|
+
? await runTargetSurvey()
|
|
304
|
+
: targetsForOptions(options);
|
|
305
|
+
const roots = skillRootsForTargets(options, selectedTargets);
|
|
306
|
+
|
|
307
|
+
if (!existsSync(source)) {
|
|
308
|
+
throw new Error(`Bundled skill folder not found: ${source}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (options.dryRun) {
|
|
312
|
+
for (const root of roots) {
|
|
313
|
+
console.log(`Would install Servicemap skill to ${join(root, 'servicemap')}`);
|
|
314
|
+
}
|
|
315
|
+
await writeGuidePacks(selectedTargets, options);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
for (const root of roots) {
|
|
320
|
+
const destination = join(root, 'servicemap');
|
|
321
|
+
await mkdir(root, { recursive: true });
|
|
322
|
+
await rm(destination, { recursive: true, force: true });
|
|
323
|
+
await cp(source, destination, { recursive: true });
|
|
324
|
+
console.log(`Installed Servicemap skill to ${destination}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
await writeGuidePacks(selectedTargets, options);
|
|
328
|
+
|
|
329
|
+
console.log('Restart your AI app if /servicemap does not appear immediately.');
|
|
330
|
+
}
|
|
331
|
+
|
|
65
332
|
async function main() {
|
|
66
333
|
const options = parseArgs();
|
|
67
334
|
if (options.help) {
|
|
@@ -69,11 +336,29 @@ async function main() {
|
|
|
69
336
|
return;
|
|
70
337
|
}
|
|
71
338
|
|
|
339
|
+
if (options.command === 'generate' && !options.explicitCommand && !options.generationRequested) {
|
|
340
|
+
options.command = 'install-skill';
|
|
341
|
+
options.survey = true;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (options.command === 'install-skill' || options.command === 'install') {
|
|
345
|
+
await installSkill(options);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (options.command !== 'generate') {
|
|
350
|
+
throw new Error(`Unknown command "${options.command}". Use generate, install-skill, or skills install.`);
|
|
351
|
+
}
|
|
352
|
+
|
|
72
353
|
if (!['auto', 'page', 'desktop'].includes(options.mode)) {
|
|
73
354
|
throw new Error(`Unknown mode "${options.mode}". Use auto, page, or desktop.`);
|
|
74
355
|
}
|
|
75
356
|
|
|
76
|
-
|
|
357
|
+
const scanArgs = [scanScript, '--root', options.root];
|
|
358
|
+
for (const email of options.allowedEmails) {
|
|
359
|
+
scanArgs.push('--allow-email', email);
|
|
360
|
+
}
|
|
361
|
+
await run(process.execPath, scanArgs);
|
|
77
362
|
const scan = await readScan(options.root);
|
|
78
363
|
const selectedMode = options.mode === 'auto'
|
|
79
364
|
? scan.project?.framework === 'static' ? 'desktop' : 'page'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "servicemap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Generate an internal service map page or desktop app for a software project.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"README.md"
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
|
-
"test": "node bin/servicemap.mjs --root demo-project --dry-run && node bin/servicemap.mjs --root no-website-demo --dry-run",
|
|
15
|
+
"test": "node bin/servicemap.mjs generate --root demo-project --allow-emails founder@example.com,ops@example.com --dry-run && node bin/servicemap.mjs --root no-website-demo --dry-run && node bin/servicemap.mjs --dry-run && HOME=\"$(mktemp -d)\" node bin/servicemap.mjs && node bin/servicemap.mjs install-skill --agent all --dry-run && node bin/servicemap.mjs install-skill --targets codex,gemini,cursor --dry-run && node bin/servicemap.mjs install-skill --targets chatgpt,gpt-6-astra,gemini-flash --dry-run && node bin/servicemap.mjs skills install --agent claude --dry-run",
|
|
16
16
|
"validate:skill": "python3 /Users/dougie/.codex/skills/.system/skill-creator/scripts/quick_validate.py servicemap"
|
|
17
17
|
},
|
|
18
18
|
"keywords": [
|
package/servicemap/SKILL.md
CHANGED
|
@@ -14,13 +14,15 @@ If no website can be found, create a desktop app fallback at `servicemap-desktop
|
|
|
14
14
|
## Workflow
|
|
15
15
|
|
|
16
16
|
1. Inspect the project layout and current web framework.
|
|
17
|
-
2.
|
|
18
|
-
3.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
2. If the user gives a list of emails that may access the generated web page, pass them to the scan command with `--allow-email <email>` for each address or `--allow-emails <comma-separated-list>`.
|
|
18
|
+
3. Run `scripts/scan-project.mjs --root <project-root>` to create `.servicemap/internal-servicemap.json`.
|
|
19
|
+
Include the access flags from the previous step when relevant.
|
|
20
|
+
4. Review the scan output for obvious false positives or missing high-confidence services.
|
|
21
|
+
5. If the scan detects a web framework or routeable website, run `scripts/generate-page.mjs --root <project-root>` to create the framework-specific `/internalservicemap` route.
|
|
22
|
+
6. If the scan reports `framework: static` or no website route can be safely attached, run `scripts/generate-desktop-app.mjs --root <project-root>` to create `servicemap-desktop`.
|
|
23
|
+
7. Adapt the generated surface to the project's existing design system only when the route is clear and the edits are low-risk.
|
|
24
|
+
8. Run the project's relevant build, lint, and focused tests.
|
|
25
|
+
9. Report the page or desktop app path, service count, validation run, any services marked low or medium confidence, and whether requested email access was enforced.
|
|
24
26
|
|
|
25
27
|
## Safety Rules
|
|
26
28
|
|
|
@@ -30,6 +32,9 @@ The scan output may include env var names such as `STRIPE_SECRET_KEY`, but not t
|
|
|
30
32
|
|
|
31
33
|
Do not add authentication, deployment, analytics, or external writes unless the user explicitly asks.
|
|
32
34
|
If the repository already has route protection conventions for internal pages, follow them.
|
|
35
|
+
When email access is requested, enforce it only through the app's existing server-side authentication.
|
|
36
|
+
The built-in generator can enforce email allowlists for Next.js App Router projects using Clerk by checking `currentUser()` against the configured emails.
|
|
37
|
+
For unsupported frameworks or static output, report that the allowlist is recorded but not enforced.
|
|
33
38
|
If no website route can be safely attached, create the desktop app fallback instead of forcing website files into an unrelated project.
|
|
34
39
|
|
|
35
40
|
## Supporting References
|
|
@@ -70,6 +70,7 @@ function pageCss() {
|
|
|
70
70
|
.service-name span, .evidence { color: #64748b; font-size: 12px; }
|
|
71
71
|
.badge { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 999px; background: #edf2ff; color: #334155; font-size: 12px; margin-bottom: 10px; }
|
|
72
72
|
.evidence { margin-top: 8px; }
|
|
73
|
+
.access-warning { margin: 0 0 18px; padding: 12px 14px; border: 1px solid #f5c542; border-radius: 8px; background: #fff7d6; color: #614700; font-size: 14px; line-height: 1.45; }
|
|
73
74
|
.empty { background: #fff; border: 1px dashed #cbd5e1; border-radius: 8px; padding: 24px; }
|
|
74
75
|
@media (max-width: 720px) { .servicemap-header { display: block; } .summary { grid-template-columns: 1fr; } }
|
|
75
76
|
`;
|
|
@@ -77,6 +78,11 @@ function pageCss() {
|
|
|
77
78
|
|
|
78
79
|
function htmlBody(graph) {
|
|
79
80
|
const services = graph.services ?? [];
|
|
81
|
+
const access = graph.access ?? {};
|
|
82
|
+
const hasAccessRequest = access.requested && access.allowedEmails?.length;
|
|
83
|
+
const accessNotice = hasAccessRequest && access.enforced !== true
|
|
84
|
+
? '<div class="access-warning">Email access was requested, but this project does not expose a supported server-side auth guard for generated pages. Put this route behind your app auth before sharing it.</div>'
|
|
85
|
+
: '';
|
|
80
86
|
const serviceCards = services.length
|
|
81
87
|
? services.map((service) => {
|
|
82
88
|
const evidence = service.evidence?.[0];
|
|
@@ -111,6 +117,7 @@ function htmlBody(graph) {
|
|
|
111
117
|
<div class="metric"><strong>${graph.summary?.highConfidenceCount ?? 0}</strong><span>high confidence</span></div>
|
|
112
118
|
<div class="metric"><strong>${escapeHtml(graph.project?.framework ?? 'unknown')}</strong><span>framework</span></div>
|
|
113
119
|
</section>
|
|
120
|
+
${accessNotice}
|
|
114
121
|
<section class="service-grid">
|
|
115
122
|
${serviceCards}
|
|
116
123
|
</section>
|
|
@@ -136,6 +143,36 @@ ${htmlBody(graph)}
|
|
|
136
143
|
|
|
137
144
|
function tsxPage(graph) {
|
|
138
145
|
const html = htmlBody(graph);
|
|
146
|
+
const allowedEmails = graph.access?.allowedEmails ?? [];
|
|
147
|
+
const usesClerk = (graph.services ?? []).some((service) => service.key === 'clerk');
|
|
148
|
+
const canEnforce = allowedEmails.length > 0 && usesClerk && graph.project?.framework === 'next-app-router';
|
|
149
|
+
if (canEnforce) {
|
|
150
|
+
graph.access.enforced = true;
|
|
151
|
+
const guardedHtml = htmlBody(graph);
|
|
152
|
+
return `import { currentUser } from '@clerk/nextjs/server';
|
|
153
|
+
import { notFound } from 'next/navigation';
|
|
154
|
+
|
|
155
|
+
const css = ${JSON.stringify(pageCss())};
|
|
156
|
+
const html = ${JSON.stringify(guardedHtml)};
|
|
157
|
+
const allowedEmails = ${JSON.stringify(allowedEmails)};
|
|
158
|
+
|
|
159
|
+
export default async function InternalServiceMapPage() {
|
|
160
|
+
const user = await currentUser();
|
|
161
|
+
const userEmails = user?.emailAddresses?.map((email) => email.emailAddress.toLowerCase()) ?? [];
|
|
162
|
+
const canView = userEmails.some((email) => allowedEmails.includes(email));
|
|
163
|
+
|
|
164
|
+
if (!canView) notFound();
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<>
|
|
168
|
+
<style dangerouslySetInnerHTML={{ __html: css }} />
|
|
169
|
+
<div dangerouslySetInnerHTML={{ __html: html }} />
|
|
170
|
+
</>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
`;
|
|
174
|
+
}
|
|
175
|
+
|
|
139
176
|
return `const css = ${JSON.stringify(pageCss())};
|
|
140
177
|
const html = ${JSON.stringify(html)};
|
|
141
178
|
|
|
@@ -65,16 +65,26 @@ function service(name, key, category, packages, envPrefixes, domains) {
|
|
|
65
65
|
|
|
66
66
|
function parseArgs() {
|
|
67
67
|
const args = process.argv.slice(2);
|
|
68
|
-
const options = { root: process.cwd(), output: defaultOutput };
|
|
68
|
+
const options = { root: process.cwd(), output: defaultOutput, allowedEmails: [] };
|
|
69
69
|
for (let index = 0; index < args.length; index += 1) {
|
|
70
70
|
if (args[index] === '--root') options.root = args[index + 1] ?? options.root;
|
|
71
71
|
if (args[index] === '--output') options.output = args[index + 1] ?? options.output;
|
|
72
|
+
if (args[index] === '--allow-email') options.allowedEmails.push(args[index + 1] ?? '');
|
|
73
|
+
if (args[index] === '--allow-emails') options.allowedEmails.push(...(args[index + 1] ?? '').split(','));
|
|
72
74
|
}
|
|
73
75
|
options.root = resolve(options.root);
|
|
74
76
|
options.output = resolve(options.root, options.output);
|
|
77
|
+
options.allowedEmails = normalizeEmails(options.allowedEmails);
|
|
75
78
|
return options;
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
function normalizeEmails(values) {
|
|
82
|
+
return [...new Set(values
|
|
83
|
+
.map((value) => value.trim().toLowerCase())
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
.filter((value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)))];
|
|
86
|
+
}
|
|
87
|
+
|
|
78
88
|
async function readJson(path) {
|
|
79
89
|
try {
|
|
80
90
|
return JSON.parse(await readFile(path, 'utf8'));
|
|
@@ -268,6 +278,10 @@ async function main() {
|
|
|
268
278
|
root: options.root,
|
|
269
279
|
framework: detectFramework(options.root, packageJson, files),
|
|
270
280
|
},
|
|
281
|
+
access: {
|
|
282
|
+
allowedEmails: options.allowedEmails,
|
|
283
|
+
requested: options.allowedEmails.length > 0,
|
|
284
|
+
},
|
|
271
285
|
services,
|
|
272
286
|
edges: buildEdges(projectName, services),
|
|
273
287
|
summary: {
|