@seomi/ssh 0.1.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 ADDED
@@ -0,0 +1,61 @@
1
+ # @seomi/ssh
2
+
3
+ > Одной командой настраивает беспарольный SSH-доступ для AI-агента к вашим серверам.
4
+
5
+ `@seomi/ssh` — CLI-инсталлятор: за один интерактивный прогон он настраивает доступ агента
6
+ по SSH-ключу к одному или нескольким серверам (dev / prod / кастомные) и записывает карту
7
+ доступа в инструкции агента (`AGENTS.md` / `CLAUDE.md`). Облегчённый родственник
8
+ [`@seomi/wp-mcp`](../seomi-wp-mcp) — без WordPress и MCP, только SSH.
9
+
10
+ ## Быстрый старт
11
+
12
+ ```bash
13
+ npm install -g @seomi/ssh
14
+ cd my-project
15
+ seomi-ssh init
16
+ ```
17
+
18
+ ## Возможности
19
+
20
+ - **`init`** — интерактивная настройка: спрашивает про серверы **в цикле** (роль, host,
21
+ user, port, ключ, рабочая директория), повторяет «добавить ещё?» до отказа. 1..N серверов.
22
+ - **SSH-визард** на каждый сервер — генерация ed25519-ключа (или переиспользование),
23
+ копирование публичного ключа (`ssh-copy-id` → ssh-pipe fallback), проверка по `BatchMode`,
24
+ при неудаче — печать ручной подсказки с содержимым `.pub`.
25
+ - **Запись инструкций агенту** — managed-блок с картой серверов и готовыми примерами
26
+ `ssh` / `scp` / `rsync` (значения берутся из `.claude/.env`).
27
+ - **Skill `aif-ssh`** — копируется в `.claude/skills/` целевого проекта и учит агента
28
+ пользоваться настроенным доступом.
29
+ - **Идемпотентность** — повторный запуск не дублирует ключи, env-записи и managed-блок.
30
+
31
+ > `update` и `doctor` объявлены в справке, но пока не реализованы (заглушки).
32
+
33
+ ## Пример
34
+
35
+ ```bash
36
+ $ seomi-ssh init
37
+ › Шаг 1: Опрос серверов
38
+ ? Роль сервера › prod
39
+ ? [prod] Host (домен или IP) › prod.example.com
40
+ ? [prod] SSH-пользователь › ai-agent
41
+ ? Добавить ещё один сервер? › No
42
+ › Шаг 2: Настройка SSH-ключей
43
+ [ok] «prod»: ключ настроен и проверен
44
+ › Шаг 3: Запись реквизитов в .claude/.env
45
+ › Шаг 4: Инструкции агенту (AGENTS.md / CLAUDE.md)
46
+ [ok] created: AGENTS.md
47
+ ```
48
+
49
+ Используйте `seomi-ssh init --dry-run`, чтобы прогнать опрос без записи на диск и SSH-вызовов.
50
+
51
+ ---
52
+
53
+ ## Документация
54
+
55
+ | Раздел | Описание |
56
+ |--------|----------|
57
+ | [Команда `init`](docs/init.md) | Настройка SSH-доступа: поведение, конфигурация `.claude/.env`, примеры, диагностика |
58
+
59
+ ## Лицензия
60
+
61
+ Условия лицензии указаны в поле `license` файла `package.json`.
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * seomi-ssh — entry point.
4
+ *
5
+ * Parses argv, sets the log level, and routes to a subcommand. Only `init` is
6
+ * implemented in this release; `update` and `doctor` are declared stubs so the
7
+ * surface is stable and discoverable.
8
+ */
9
+
10
+ import { parseArgs } from 'node:util';
11
+ import { initCommand } from '../src/commands/init.mjs';
12
+ import { logger } from '../src/lib/logger.mjs';
13
+
14
+ const HELP = `Usage: seomi-ssh <command> [options]
15
+
16
+ Commands:
17
+ init Interactive setup — configure passwordless SSH access for an
18
+ AI agent to one or more servers (dev / prod / custom), write
19
+ the connection params to .claude/.env, render the managed
20
+ access block into AGENTS.md / CLAUDE.md, and drop the aif-ssh
21
+ skill into .claude/skills/.
22
+ update (not implemented yet) Regenerate the managed block from
23
+ .claude/.env and self-update the package.
24
+ doctor (not implemented yet) Diagnose configured servers (which are
25
+ set up, which are reachable by key).
26
+
27
+ Global options:
28
+ --verbose Enable debug logging (DEBUG level).
29
+ --dry-run init only: run prompts but make no changes; preview the block.
30
+ --help, -h Show this help.
31
+ --version, -v Print version.
32
+ `;
33
+
34
+ async function readVersion() {
35
+ const { readFile } = await import( 'node:fs/promises' );
36
+ const pkg = JSON.parse(
37
+ await readFile( new URL( '../package.json', import.meta.url ), 'utf8' )
38
+ );
39
+ return pkg.version;
40
+ }
41
+
42
+ async function main() {
43
+ const rawArgs = process.argv.slice( 2 );
44
+
45
+ if ( rawArgs.length === 0 || rawArgs[0] === '--help' || rawArgs[0] === '-h' ) {
46
+ process.stdout.write( HELP );
47
+ return 0;
48
+ }
49
+
50
+ if ( rawArgs[0] === '--version' || rawArgs[0] === '-v' ) {
51
+ process.stdout.write( ( await readVersion() ) + '\n' );
52
+ return 0;
53
+ }
54
+
55
+ const command = rawArgs[0];
56
+ const restArgs = rawArgs.slice( 1 );
57
+
58
+ const { values, positionals } = parseArgs( {
59
+ args: restArgs,
60
+ options: {
61
+ verbose: { type: 'boolean', default: false },
62
+ help: { type: 'boolean', short: 'h', default: false },
63
+ 'dry-run': { type: 'boolean', default: false },
64
+ },
65
+ strict: false,
66
+ allowPositionals: true,
67
+ } );
68
+
69
+ if ( values.verbose ) {
70
+ logger.setLevel( 'debug' );
71
+ }
72
+
73
+ const opts = { ...values, positionals };
74
+
75
+ switch ( command ) {
76
+ case 'init':
77
+ return await initCommand( opts );
78
+ case 'update':
79
+ case 'doctor':
80
+ logger.warn( `Команда «${ command }» ещё не реализована в этой версии.` );
81
+ logger.info( 'Доступна только `seomi-ssh init`. См. `seomi-ssh --help`.' );
82
+ return 1;
83
+ default:
84
+ logger.error( `Unknown command: ${ command }` );
85
+ process.stdout.write( HELP );
86
+ return 1;
87
+ }
88
+ }
89
+
90
+ main()
91
+ .then( ( code ) => process.exit( code ?? 0 ) )
92
+ .catch( ( err ) => {
93
+ logger.error( 'Unhandled error:', err?.stack || err?.message || String( err ) );
94
+ process.exit( 1 );
95
+ } );
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@seomi/ssh",
3
+ "version": "0.1.0",
4
+ "description": "One-command CLI installer that sets up passwordless SSH access for AI agents in any project (dev/prod/custom servers) and writes the access map into AGENTS.md / CLAUDE.md.",
5
+ "type": "module",
6
+ "bin": {
7
+ "seomi-ssh": "bin/seomi-ssh.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "skills",
13
+ "templates",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "scripts": {
21
+ "test": "node --test \"test/*.test.mjs\""
22
+ },
23
+ "dependencies": {
24
+ "@inquirer/prompts": "^7.0.0"
25
+ },
26
+ "keywords": [
27
+ "ssh",
28
+ "ai-agent",
29
+ "claude",
30
+ "ai-factory",
31
+ "seomi",
32
+ "cli",
33
+ "deploy"
34
+ ],
35
+ "license": "SEE LICENSE IN LICENSE",
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "directories": {
40
+ "test": "test"
41
+ }
42
+ }
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: aif-ssh
3
+ description: Use the SSH access that `seomi-ssh init` configured for this project — run remote commands, deploy files (scp/rsync), and diagnose servers. Connection parameters live in `.claude/.env` under SSH_<ROLE>_* keys. Trigger when the task needs work on a remote server (deploy, run a command remotely, tail logs, check disk/service status) or when the user mentions prod/dev/staging access.
4
+ ---
5
+
6
+ # aif-ssh — использование SSH-доступа агентом
7
+
8
+ Этот проект настроен через `@seomi/ssh`: для одного или нескольких серверов уже
9
+ сконфигурирован беспарольный SSH-доступ (ed25519-ключ), а реквизиты записаны в
10
+ `.claude/.env`. Этот skill учит, как этим доступом пользоваться.
11
+
12
+ ## Где брать реквизиты доступа
13
+
14
+ **Первое и единственное место — `.claude/.env`.** Не используй `~/.ssh/config`, алиасы из
15
+ других проектов и не угадывай host по имени проекта.
16
+
17
+ Каждый сервер описан группой ключей с префиксом роли:
18
+
19
+ ```
20
+ SSH_SERVERS=PROD,DEV # реестр настроенных серверов (csv префиксов)
21
+
22
+ SSH_PROD_HOST=prod.example.com
23
+ SSH_PROD_USER=ai-agent
24
+ SSH_PROD_PORT=22
25
+ SSH_PROD_KEY=~/.ssh/id_ed25519
26
+ SSH_PROD_ROOT=/var/www/app # необязательный — рабочая директория
27
+ ```
28
+
29
+ Алгоритм: прочитай `SSH_SERVERS`, выбери нужный префикс (`PROD` / `DEV` / кастомный),
30
+ подставь `SSH_<PREFIX>_HOST/USER/PORT/KEY/ROOT` в команды ниже.
31
+
32
+ ## Готовые команды
33
+
34
+ Пусть `H=$SSH_<PREFIX>_HOST`, `U=$SSH_<PREFIX>_USER`, `P=$SSH_<PREFIX>_PORT`,
35
+ `K=$SSH_<PREFIX>_KEY`, `R=$SSH_<PREFIX>_ROOT`. Флаг порта добавляй только если порт ≠ 22.
36
+
37
+ ```bash
38
+ # Выполнить команду на сервере
39
+ ssh -p "$P" -i "$K" "$U@$H" "<command>"
40
+
41
+ # Скопировать файл/каталог на сервер (порт у scp — заглавная -P)
42
+ scp -P "$P" -i "$K" -r ./local-path "$U@$H:$R/"
43
+
44
+ # Инкрементальная синхронизация каталога (предпочтительно для повторных деплоев)
45
+ rsync -avz -e "ssh -i $K -p $P" ./local-dir/ "$U@$H:$R/"
46
+
47
+ # Просмотр логов / статуса
48
+ ssh -p "$P" -i "$K" "$U@$H" "tail -n 100 $R/storage/logs/app.log"
49
+ ```
50
+
51
+ ## Правила
52
+
53
+ - **`.claude/.env` — источник истины.** Если ключа нет в `.claude/.env`, доступ к этому
54
+ серверу не настроен — не выдумывай реквизиты, предложи запустить `seomi-ssh init`.
55
+ - **Не логируй и не выводи содержимое приватного ключа** (`$SSH_<PREFIX>_KEY` указывает на
56
+ файл — путь показывать можно, содержимое — нет).
57
+ - **Деплой через SSH/scp/rsync — канонический путь.** Не предлагай GUI-клиенты
58
+ (PhpStorm/WebStorm Deploy, Cyberduck, FileZilla) первым вариантом.
59
+ - **Перед разрушительными операциями на сервере** (`rm -rf`, перезапись, миграции БД,
60
+ рестарт сервисов) — подтверди у пользователя; одобрение для одного сервера не
61
+ распространяется на другие.
62
+ - **Не путай окружения.** Перед командой на `PROD` убедись, что выбран правильный префикс;
63
+ для экспериментов предпочитай `DEV`/`staging`, если он настроен.
64
+
65
+ ## Управление доступом
66
+
67
+ - `seomi-ssh init` — первичная настройка (добавить серверы, скопировать ключ, записать блок).
68
+ - `seomi-ssh update` — перегенерировать managed-блок в `AGENTS.md`/`CLAUDE.md` после
69
+ изменения реквизитов в `.claude/.env`.
70
+ - `seomi-ssh doctor` — проверить, какие серверы настроены и доступны ли они по ключу.
@@ -0,0 +1,147 @@
1
+ /**
2
+ * `seomi-ssh init` — interactive setup orchestrator.
3
+ *
4
+ * Thin command: it sequences lib utilities and owns no business logic itself.
5
+ * Flow:
6
+ * 1. promptServers() — interactive loop (1..N servers)
7
+ * 2. ensureSshKey() per server — keygen → copy → verify → manual hint
8
+ * 3. mergeEnv() — write SSH_<PREFIX>_* + SSH_SERVERS to .claude/.env
9
+ * 4. detectAgentMdTargets() — pick AGENTS.md / CLAUDE.md / both
10
+ * 5. renderAgentMdBlock() + insertOrUpdate() — managed block per target
11
+ * 6. copy skills/aif-ssh — drop the access skill into .claude/skills/
12
+ *
13
+ * `--dry-run` runs the prompts but performs NO side effects (no ssh, no file
14
+ * writes); it prints what would happen and the rendered block instead.
15
+ */
16
+
17
+ import { mkdir, copyFile } from 'node:fs/promises';
18
+ import { join } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { logger } from '../lib/logger.mjs';
21
+ import { promptServers, toEnvUpdates } from '../lib/server-prompt.mjs';
22
+ import { mergeEnv } from '../lib/env-writer.mjs';
23
+ import { ensureSshKey } from '../lib/ssh-key-setup.mjs';
24
+ import { detectAgentMdTargets } from '../lib/agent-md-target.mjs';
25
+ import { renderAgentMdBlock } from '../lib/agent-md-renderer.mjs';
26
+ import { insertOrUpdate } from '../lib/markers.mjs';
27
+
28
+ const MARKER_NS = 'seomi-ssh';
29
+
30
+ function packagePath( ...segments ) {
31
+ // init.mjs lives at <pkg>/src/commands/ — climb two levels to the package root.
32
+ return fileURLToPath( new URL( join( '../../', ...segments ), import.meta.url ) );
33
+ }
34
+
35
+ async function copySkill( cwd ) {
36
+ const src = packagePath( 'skills', 'aif-ssh', 'SKILL.md' );
37
+ const destDir = join( cwd, '.claude', 'skills', 'aif-ssh' );
38
+ await mkdir( destDir, { recursive: true } );
39
+ const dest = join( destDir, 'SKILL.md' );
40
+ await copyFile( src, dest );
41
+ logger.success( `skill aif-ssh → ${ dest }` );
42
+ }
43
+
44
+ function printSummary( servers, dryRun ) {
45
+ logger.info( `Серверов настроено: ${ servers.length }${ dryRun ? ' (dry-run, без записи)' : '' }` );
46
+ for ( const s of servers ) {
47
+ logger.info( ` • ${ s.role } → ${ s.user }@${ s.host }${ s.port && s.port !== '22' ? `:${ s.port }` : '' } (env SSH_${ s.prefix }_*)` );
48
+ }
49
+ }
50
+
51
+ /**
52
+ * @param {object} [options]
53
+ * @param {string} [options.cwd] — project root (default process.cwd()).
54
+ * @param {boolean} [options['dry-run']] — no side effects, just preview.
55
+ * @param {string} [options.envPath] — override .claude/.env location.
56
+ * @param {string} [options.templatePath] — override bundled template path.
57
+ * @param {object} [options._prompts] — test seam for @inquirer/prompts.
58
+ * @param {Function} [options._promptSelect] — test seam for agent-md-target select.
59
+ * @returns {Promise<number>} process exit code.
60
+ */
61
+ export async function initCommand( options = {} ) {
62
+ const cwd = options.cwd || process.cwd();
63
+ const dryRun = Boolean( options[ 'dry-run' ] || options.dryRun );
64
+ const envPath = options.envPath || join( cwd, '.claude', '.env' );
65
+ const templatePath = options.templatePath || packagePath( 'templates', 'agent-md-block.md' );
66
+
67
+ logger.step( 'seomi-ssh init' + ( dryRun ? ' (dry-run)' : '' ) );
68
+
69
+ // --- 1. Prompt for servers -------------------------------------------
70
+ logger.step( 'Шаг 1: Опрос серверов' );
71
+ const servers = await promptServers( { _prompts: options._prompts } );
72
+ if ( servers.length === 0 ) {
73
+ logger.warn( 'Серверы не заданы — нечего настраивать. Выход.' );
74
+ return 0;
75
+ }
76
+ logger.success( `Введено серверов: ${ servers.length }` );
77
+
78
+ // --- 2. SSH key wizard per server ------------------------------------
79
+ logger.step( 'Шаг 2: Настройка SSH-ключей' );
80
+ for ( const srv of servers ) {
81
+ if ( dryRun ) {
82
+ logger.info( `[dry-run] ensureSshKey для «${ srv.role }» (${ srv.user }@${ srv.host }) пропущен` );
83
+ continue;
84
+ }
85
+ try {
86
+ const result = await ensureSshKey( {
87
+ sshHost: srv.host,
88
+ sshUser: srv.user,
89
+ sshPort: srv.port,
90
+ keyPath: srv.keyPath,
91
+ } );
92
+ if ( result.verified ) {
93
+ logger.success( `«${ srv.role }»: ключ настроен и проверен (${ result.keygenAction }/${ result.copyAction })` );
94
+ } else {
95
+ logger.warn( `«${ srv.role }»: автоматическая настройка не удалась — ручная подсказка:` );
96
+ logger.warn( result.manualHint );
97
+ }
98
+ } catch ( err ) {
99
+ // One server's failure must not abort the rest of the run.
100
+ logger.error( `«${ srv.role }»: ${ err.message }` );
101
+ }
102
+ }
103
+
104
+ // --- 3. Write connection params to .claude/.env ----------------------
105
+ logger.step( 'Шаг 3: Запись реквизитов в .claude/.env' );
106
+ const updates = toEnvUpdates( servers );
107
+ if ( dryRun ) {
108
+ logger.info( `[dry-run] mergeEnv пропущен; были бы записаны ключи: ${ Object.keys( updates ).join( ', ' ) }` );
109
+ } else {
110
+ const r = await mergeEnv( envPath, updates );
111
+ logger.success( `.claude/.env: добавлено ${ r.added.length }, обновлено ${ r.updated.length }, без изменений ${ r.unchanged.length }` );
112
+ }
113
+
114
+ // --- 4 & 5. Render + write the managed block -------------------------
115
+ logger.step( 'Шаг 4: Инструкции агенту (AGENTS.md / CLAUDE.md)' );
116
+ const block = await renderAgentMdBlock( servers, templatePath );
117
+ if ( dryRun ) {
118
+ const { targets } = await detectAgentMdTargets( { cwd, interactive: false } );
119
+ logger.info( `[dry-run] managed-блок был бы записан в: ${ targets.join( ', ' ) || '(none)' }` );
120
+ process.stdout.write( '\n--- managed block preview ---\n' + block + '--- end preview ---\n' );
121
+ } else {
122
+ const { targets } = await detectAgentMdTargets( {
123
+ cwd,
124
+ interactive: true,
125
+ _promptSelect: options._promptSelect,
126
+ } );
127
+ if ( targets.length === 0 ) {
128
+ logger.warn( 'Целевой файл инструкций не выбран — managed-блок не записан.' );
129
+ }
130
+ for ( const file of targets ) {
131
+ const res = await insertOrUpdate( file, block, { namespace: MARKER_NS } );
132
+ logger.success( `${ res.action }: ${ file }` );
133
+ }
134
+ }
135
+
136
+ // --- 6. Copy the access skill ----------------------------------------
137
+ logger.step( 'Шаг 5: Копирование skill aif-ssh' );
138
+ if ( dryRun ) {
139
+ logger.info( `[dry-run] копирование skills/aif-ssh → ${ join( cwd, '.claude', 'skills', 'aif-ssh', 'SKILL.md' ) } пропущено` );
140
+ } else {
141
+ await copySkill( cwd );
142
+ }
143
+
144
+ logger.step( 'Готово' );
145
+ printSummary( servers, dryRun );
146
+ return 0;
147
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Render the managed AGENTS.md / CLAUDE.md block from the bundled template.
3
+ *
4
+ * Input is the list of server objects produced by `server-prompt.mjs`
5
+ * (role / prefix / host / user / port / keyPath / root). The block is two
6
+ * generated parts spliced into the template:
7
+ *
8
+ * {{SERVER_TABLE}} — one row per server (role, env-prefix, host, user, …)
9
+ * {{SERVER_EXAMPLES}} — ssh / scp / rsync recipes per server, with the port
10
+ * flag and key path pre-filled from the entered values
11
+ *
12
+ * The recipes exist because agents otherwise default to `~/.ssh/config` aliases
13
+ * or GUI deploy clients; pre-cooking concrete `scp`/`rsync` commands keyed off
14
+ * `.claude/.env` keeps them on the canonical channel.
15
+ *
16
+ * Self-contained: only depends on the shared logger. No external commands.
17
+ */
18
+
19
+ import { readFile } from 'node:fs/promises';
20
+ import { logger } from './logger.mjs';
21
+
22
+ function present( v ) {
23
+ return typeof v === 'string' && v.trim().length > 0;
24
+ }
25
+
26
+ /**
27
+ * A non-default port (anything other than blank/22) needs an explicit flag in
28
+ * the example commands. 22 is the ssh default, so we omit it for cleaner copy.
29
+ */
30
+ function effectivePort( port ) {
31
+ return present( port ) && port.trim() !== '22' ? port.trim() : '';
32
+ }
33
+
34
+ function buildServerTable( servers ) {
35
+ const out = [
36
+ '| Роль | env-префикс | Host | User | Port | Рабочая директория |',
37
+ '|------|-------------|------|------|------|--------------------|',
38
+ ];
39
+ for ( const s of servers ) {
40
+ out.push(
41
+ `| ${ s.role } | \`SSH_${ s.prefix }_*\` | ${ s.host } | ${ s.user } | ${ present( s.port ) ? s.port : '22' } | ${ present( s.root ) ? `\`${ s.root }\`` : '—' } |`
42
+ );
43
+ }
44
+ return out.join( '\n' );
45
+ }
46
+
47
+ function buildServerExamples( servers ) {
48
+ const blocks = [];
49
+ for ( const s of servers ) {
50
+ const port = effectivePort( s.port );
51
+ const sshPortFlag = port ? `-p ${ port } ` : '';
52
+ const scpPortFlag = port ? `-P ${ port } ` : '';
53
+ const key = present( s.keyPath ) ? s.keyPath : '~/.ssh/id_ed25519';
54
+ const target = `${ s.user }@${ s.host }`;
55
+ const root = present( s.root ) ? s.root : '<remote-path>';
56
+ const rsyncSsh = `-e "ssh -i ${ key }${ port ? ` -p ${ port }` : '' }"`;
57
+
58
+ const lines = [];
59
+ lines.push( `#### ${ s.role } — \`${ target }\` (env \`SSH_${ s.prefix }_*\`)` );
60
+ lines.push( '' );
61
+ lines.push( '```bash' );
62
+ lines.push( '# Выполнить команду на сервере' );
63
+ lines.push( `ssh ${ sshPortFlag }-i ${ key } ${ target } "<command>"` );
64
+ lines.push( '' );
65
+ lines.push( '# Скопировать файл/каталог на сервер' );
66
+ lines.push( `scp ${ scpPortFlag }-i ${ key } -r ./local-path ${ target }:${ root }/` );
67
+ lines.push( '' );
68
+ lines.push( '# Инкрементальная синхронизация каталога (рекомендуется для повторных деплоев)' );
69
+ lines.push( `rsync -avz ${ rsyncSsh } ./local-dir/ ${ target }:${ root }/` );
70
+ lines.push( '```' );
71
+ blocks.push( lines.join( '\n' ) );
72
+ }
73
+ return blocks.join( '\n\n' );
74
+ }
75
+
76
+ /**
77
+ * Render the managed block.
78
+ *
79
+ * @param {Array<object>} servers Server objects from `promptServers`.
80
+ * @param {string} templatePath Path to `templates/agent-md-block.md`.
81
+ * @returns {Promise<string>} The rendered block (no marker comments —
82
+ * `markers.insertOrUpdate` wraps it).
83
+ */
84
+ export async function renderAgentMdBlock( servers, templatePath ) {
85
+ if ( ! Array.isArray( servers ) || servers.length === 0 ) {
86
+ throw new Error( 'renderAgentMdBlock: at least one server is required' );
87
+ }
88
+ logger.debug( `[render] rendering block for ${ servers.length } server(s) from ${ templatePath }` );
89
+
90
+ const template = await readFile( templatePath, 'utf8' );
91
+ const rendered = template
92
+ .replaceAll( '{{SERVER_TABLE}}', buildServerTable( servers ) )
93
+ .replaceAll( '{{SERVER_EXAMPLES}}', buildServerExamples( servers ) );
94
+
95
+ // Collapse runs of 3+ blank lines for stable diffs across re-renders.
96
+ return rendered.replace( /\n{3,}/g, '\n\n' ).trim() + '\n';
97
+ }
98
+
99
+ export const _internals = { buildServerTable, buildServerExamples, effectivePort };
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Target-aware detection for AI agent instructions files (AGENTS.md / CLAUDE.md).
3
+ *
4
+ * Claude Code reads `CLAUDE.md` if present and IGNORES `AGENTS.md` next to it.
5
+ * Other agents (e.g. ai-factory tooling) treat `AGENTS.md` as the universal
6
+ * standard. This detector lets `init` / `update` / `doctor` keep the managed
7
+ * `seomi-ssh` block in the file the project actually uses — and, when both
8
+ * coexist, synchronize the block into both so no agent loses context.
9
+ *
10
+ * Decision tree:
11
+ * 1. Both files exist → targets = [AGENTS.md, CLAUDE.md] (source='both')
12
+ * 2. Only AGENTS.md exists → targets = [AGENTS.md] (source='agents')
13
+ * 3. Only CLAUDE.md exists → targets = [CLAUDE.md] (source='claude')
14
+ * 4. Neither, interactive=false→ targets = [<defaultName>] (source='default')
15
+ * 5. Neither, interactive=true → select prompt → user / skipped (source='user'|'skipped')
16
+ */
17
+
18
+ import { existsSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { logger } from './logger.mjs';
21
+
22
+ export const DEFAULT_TARGET = 'AGENTS.md';
23
+ const AGENTS_FILE = 'AGENTS.md';
24
+ const CLAUDE_FILE = 'CLAUDE.md';
25
+
26
+ /**
27
+ * Detect which agent-instructions file(s) the project uses and return absolute
28
+ * paths to be updated with the managed `seomi-ssh` block.
29
+ *
30
+ * @param {object} opts
31
+ * @param {string} opts.cwd — project root
32
+ * @param {boolean} [opts.interactive=false]— allowed to prompt the user when
33
+ * neither file exists
34
+ * @param {string} [opts.defaultName='AGENTS.md'] — fallback when no file
35
+ * exists and we cannot prompt
36
+ * @param {Function} [opts._promptSelect] — test seam: replaces the
37
+ * `@inquirer/prompts` `select`
38
+ * call. Called with the same
39
+ * options as `select()` and must
40
+ * return a Promise<string>.
41
+ * @returns {Promise<{ targets: string[], source: 'both'|'agents'|'claude'|'default'|'user'|'skipped' }>}
42
+ */
43
+ export async function detectAgentMdTargets( { cwd, interactive = false, defaultName = DEFAULT_TARGET, _promptSelect } = {} ) {
44
+ if ( ! cwd ) throw new Error( 'detectAgentMdTargets: `cwd` is required' );
45
+
46
+ logger.debug( `[agent-md] cwd=${ cwd }, interactive=${ interactive }, default=${ defaultName }` );
47
+
48
+ const agentsPath = join( cwd, AGENTS_FILE );
49
+ const claudePath = join( cwd, CLAUDE_FILE );
50
+ const existsAgents = existsSync( agentsPath );
51
+ const existsClaude = existsSync( claudePath );
52
+
53
+ logger.info( `[agent-md] AGENTS.md exists: ${ existsAgents }, CLAUDE.md exists: ${ existsClaude }` );
54
+
55
+ if ( existsAgents && existsClaude ) {
56
+ logger.info( '[agent-md] decision: both' );
57
+ logger.warn( '[agent-md] both files present — managed block will be synced to both, prefer keeping only AGENTS.md long-term' );
58
+ const targets = [ agentsPath, claudePath ];
59
+ logger.success( `[agent-md] targets: ${ targets.join( ', ' ) }` );
60
+ return { targets, source: 'both' };
61
+ }
62
+
63
+ if ( existsAgents ) {
64
+ logger.info( '[agent-md] decision: agents-only' );
65
+ const targets = [ agentsPath ];
66
+ logger.success( `[agent-md] targets: ${ targets.join( ', ' ) }` );
67
+ return { targets, source: 'agents' };
68
+ }
69
+
70
+ if ( existsClaude ) {
71
+ logger.info( '[agent-md] decision: claude-only' );
72
+ const targets = [ claudePath ];
73
+ logger.success( `[agent-md] targets: ${ targets.join( ', ' ) }` );
74
+ return { targets, source: 'claude' };
75
+ }
76
+
77
+ // Neither file exists.
78
+ if ( ! interactive ) {
79
+ logger.info( `[agent-md] decision: default (${ defaultName })` );
80
+ const targets = [ join( cwd, defaultName ) ];
81
+ logger.success( `[agent-md] targets: ${ targets.join( ', ' ) }` );
82
+ return { targets, source: 'default' };
83
+ }
84
+
85
+ // Interactive: ask the user which file to create.
86
+ const select = _promptSelect || ( await import( '@inquirer/prompts' ) ).select;
87
+ const choice = await select( {
88
+ message: 'Project has no AGENTS.md or CLAUDE.md. Create which one?',
89
+ default: AGENTS_FILE,
90
+ choices: [
91
+ { name: 'AGENTS.md (universal, recommended)', value: AGENTS_FILE },
92
+ { name: 'CLAUDE.md (Claude Code only)', value: CLAUDE_FILE },
93
+ { name: 'skip — do not create any file', value: 'skip' },
94
+ ],
95
+ } );
96
+
97
+ if ( choice === 'skip' ) {
98
+ logger.info( '[agent-md] decision: skipped' );
99
+ logger.success( '[agent-md] targets: (none)' );
100
+ return { targets: [], source: 'skipped' };
101
+ }
102
+
103
+ logger.info( `[agent-md] decision: user-chosen (${ choice })` );
104
+ const targets = [ join( cwd, choice ) ];
105
+ logger.success( `[agent-md] targets: ${ targets.join( ', ' ) }` );
106
+ return { targets, source: 'user' };
107
+ }