@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 +61 -0
- package/bin/seomi-ssh.mjs +95 -0
- package/package.json +42 -0
- package/skills/aif-ssh/SKILL.md +70 -0
- package/src/commands/init.mjs +147 -0
- package/src/lib/agent-md-renderer.mjs +99 -0
- package/src/lib/agent-md-target.mjs +107 -0
- package/src/lib/env-writer.mjs +90 -0
- package/src/lib/logger.mjs +53 -0
- package/src/lib/markers.mjs +132 -0
- package/src/lib/server-prompt.mjs +176 -0
- package/src/lib/ssh-key-setup.mjs +236 -0
- package/templates/agent-md-block.md +19 -0
- package/templates/claude-dotenv/.env.example +22 -0
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
|
+
}
|