@seomi/ssh 0.1.0 → 0.1.2

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/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) SEOMI. All rights reserved.
2
+
3
+ This software is proprietary and confidential.
4
+
5
+ Permitted use:
6
+ - Internal use within SEOMI and on projects developed or maintained by SEOMI.
7
+ - Use by SEOMI clients on their own projects as part of services provided by SEOMI.
8
+
9
+ Not permitted without prior written consent from SEOMI:
10
+ - Redistribution, sublicensing, or sale of this software or any substantial portion
11
+ of it.
12
+ - Reverse-engineering for the purpose of creating a competing product.
13
+ - Removal of copyright or license notices.
14
+
15
+ THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
16
+ INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
17
+ PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR
18
+ ANY CLAIM, DAMAGES OR OTHER LIABILITY ARISING FROM, OUT OF OR IN CONNECTION WITH THE
19
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,13 +1,14 @@
1
1
  # @seomi/ssh
2
2
 
3
- > Одной командой настраивает беспарольный SSH-доступ для AI-агента к вашим серверам.
3
+ **English** | [Русский](https://github.com/Mikeekb/seomi-ssh/blob/main/README.ru.md)
4
4
 
5
- `@seomi/ssh` CLI-инсталлятор: за один интерактивный прогон он настраивает доступ агента
6
- по SSH-ключу к одному или нескольким серверам (dev / prod / кастомные) и записывает карту
7
- доступа в инструкции агента (`AGENTS.md` / `CLAUDE.md`). Облегчённый родственник
8
- [`@seomi/wp-mcp`](../seomi-wp-mcp) — без WordPress и MCP, только SSH.
5
+ > One command sets up passwordless SSH access for an AI agent to your servers.
9
6
 
10
- ## Быстрый старт
7
+ `@seomi/ssh` is a CLI installer: in a single interactive pass it configures key-based SSH
8
+ access for an agent to one or more servers (dev / prod / custom) and writes an access map
9
+ into the agent instructions (`AGENTS.md` / `CLAUDE.md`). It's a lightweight sibling of
10
+ [`@seomi/wp-mcp`](https://github.com/Mikeekb/seomi-wp-mcp) — the SSH wizard and agent-instruction
11
+ logic, without the WordPress and MCP parts.
11
12
 
12
13
  ```bash
13
14
  npm install -g @seomi/ssh
@@ -15,47 +16,103 @@ cd my-project
15
16
  seomi-ssh init
16
17
  ```
17
18
 
18
- ## Возможности
19
+ ## Features
19
20
 
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-блок.
21
+ - **`init`** — interactive setup. Asks about servers **in a loop** (role, host, user, port,
22
+ key path, optional working directory) and repeats "add another?" until you decline.
23
+ Supports any number of servers: prod only; dev + prod; or an arbitrary set.
24
+ - **SSH wizard per server** — generates an ed25519 key (or reuses one), copies the public
25
+ key (`ssh-copy-id` ssh-pipe fallback), verifies with `ssh -o BatchMode=yes`, and on
26
+ failure prints a manual hint with the `.pub` contents.
27
+ - **Writes agent instructions** a managed block with a server map and ready-to-use
28
+ `ssh` / `scp` / `rsync` examples, values pulled from `.claude/.env`.
29
+ - **`aif-ssh` skill** — copied into the project's `.claude/skills/` so the agent knows how
30
+ to use the configured access.
31
+ - **Idempotent** — re-running never duplicates keys, env entries, or the managed block.
30
32
 
31
- > `update` и `doctor` объявлены в справке, но пока не реализованы (заглушки).
33
+ > `update` and `doctor` are declared in `--help` but are not implemented yet (stubs).
32
34
 
33
- ## Пример
35
+ ## Commands
36
+
37
+ | Command | What it does |
38
+ |---------|--------------|
39
+ | `seomi-ssh init` | Interactive setup (see above) |
40
+ | `seomi-ssh init --dry-run` | Run the prompts without touching disk or SSH; preview the block |
41
+ | `seomi-ssh init --verbose` | Add debug-level logging |
42
+ | `seomi-ssh --help` | Show usage |
43
+ | `seomi-ssh --version` | Print version |
44
+ | `seomi-ssh update` / `doctor` | Reserved (not implemented yet) |
45
+
46
+ ## Requirements
47
+
48
+ - **Node 20+** for the CLI itself.
49
+ - **OpenSSH client** (`ssh`, `ssh-keygen`) on the local machine. `ssh-copy-id` is optional —
50
+ without it (typical on Windows OpenSSH) the wizard uses a portable ssh-pipe fallback.
51
+
52
+ ## How the SSH wizard works
53
+
54
+ For each server, a strategy chain with graceful degradation:
55
+
56
+ 1. **Keygen** — generate `ed25519` if the key is missing (`ssh-keygen -N ''`, empty passphrase
57
+ so the agent can authenticate non-interactively), otherwise reuse the existing key.
58
+ 2. **Copy** — `ssh-copy-id` (asks for the password once). If the binary isn't on PATH, fall
59
+ back to piping the public key into `~/.ssh/authorized_keys` over `ssh` (deduplicated).
60
+ 3. **Verify** — `ssh -o BatchMode=yes ... 'echo ok'`. BatchMode disables password prompts,
61
+ so a non-zero exit reliably means the key wasn't accepted.
62
+ 4. **Fallback** — on failed verification, print a manual hint with the `.pub` contents.
63
+
64
+ A failure on one server doesn't abort the run — the others continue.
65
+
66
+ ## Configuration
67
+
68
+ Connection parameters live in `.claude/.env` (gitignored) as flat keys with a **role prefix**.
69
+ One server = one key group:
70
+
71
+ | Key | Purpose | Written |
72
+ |-----|---------|---------|
73
+ | `SSH_<PREFIX>_HOST` | domain or IP | always |
74
+ | `SSH_<PREFIX>_USER` | SSH user | always |
75
+ | `SSH_<PREFIX>_PORT` | port | if provided |
76
+ | `SSH_<PREFIX>_KEY` | private key path | always |
77
+ | `SSH_<PREFIX>_ROOT` | remote working directory | if provided |
78
+ | `SSH_SERVERS` | csv registry of all prefixes | always |
79
+
80
+ `<PREFIX>` is the role normalized to `UPPER_SNAKE_CASE` (`prod` → `PROD`, `staging-eu` →
81
+ `STAGING_EU`); duplicate roles get a unique suffix (`PROD`, then `PROD_2`). `.claude/.env`
82
+ is the **first place** the agent looks for access details — not `~/.ssh/config`.
83
+
84
+ ## Example
34
85
 
35
86
  ```bash
36
87
  $ 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)
88
+ Step 1: Server prompts
89
+ ? Server role › prod
90
+ ? [prod] Host (domain or IP) › prod.example.com
91
+ ? [prod] SSH user › ai-agent
92
+ ? Add another server? › No
93
+ Step 2: SSH keys
94
+ [ok] "prod": key configured and verified
95
+ Step 4: Agent instructions (AGENTS.md / CLAUDE.md)
46
96
  [ok] created: AGENTS.md
47
97
  ```
48
98
 
49
- Используйте `seomi-ssh init --dry-run`, чтобы прогнать опрос без записи на диск и SSH-вызовов.
50
-
51
99
  ---
52
100
 
53
- ## Документация
101
+ ## Documentation
102
+
103
+ | Guide | Description |
104
+ |-------|-------------|
105
+ | [`init` command](https://github.com/Mikeekb/seomi-ssh/blob/main/docs/init.md) | SSH setup behavior, `.claude/.env` configuration, examples, troubleshooting |
54
106
 
55
- | Раздел | Описание |
56
- |--------|----------|
57
- | [Команда `init`](docs/init.md) | Настройка SSH-доступа: поведение, конфигурация `.claude/.env`, примеры, диагностика |
107
+ ## License
58
108
 
59
- ## Лицензия
109
+ Proprietary — © SEOMI. See `LICENSE`.
110
+
111
+ ## Related projects
112
+
113
+ - [@seomi/wp-mcp](https://github.com/Mikeekb/seomi-wp-mcp) — the WordPress/MCP sibling this package is derived from.
114
+ - [ai-factory](https://github.com/lee-to/ai-factory) — companion project for AI dev context.
115
+
116
+ ---
60
117
 
61
- Условия лицензии указаны в поле `license` файла `package.json`.
118
+ Built and maintained by [SEOmi.ru Web Development](https://seomi.ru/).
package/README.ru.md ADDED
@@ -0,0 +1,118 @@
1
+ # @seomi/ssh
2
+
3
+ [English](https://github.com/Mikeekb/seomi-ssh/blob/main/README.md) | **Русский**
4
+
5
+ > Одной командой настраивает беспарольный SSH-доступ для AI-агента к вашим серверам.
6
+
7
+ `@seomi/ssh` — CLI-инсталлятор: за один интерактивный прогон он настраивает доступ агента
8
+ по SSH-ключу к одному или нескольким серверам (dev / prod / кастомные) и записывает карту
9
+ доступа в инструкции агента (`AGENTS.md` / `CLAUDE.md`). Облегчённый родственник
10
+ [`@seomi/wp-mcp`](https://github.com/Mikeekb/seomi-wp-mcp) — взяты только SSH-визард и логика
11
+ инструкций агенту, без WordPress и MCP.
12
+
13
+ ```bash
14
+ npm install -g @seomi/ssh
15
+ cd my-project
16
+ seomi-ssh init
17
+ ```
18
+
19
+ ## Возможности
20
+
21
+ - **`init`** — интерактивная настройка. Спрашивает про серверы **в цикле** (роль, host,
22
+ user, port, путь к ключу, опционально рабочая директория) и повторяет «добавить ещё?»,
23
+ пока не откажешься. Любое число серверов: только prod; dev + prod; произвольный набор.
24
+ - **SSH-визард на каждый сервер** — генерация ed25519-ключа (или переиспользование),
25
+ копирование публичного ключа (`ssh-copy-id` → ssh-pipe fallback), проверка по
26
+ `ssh -o BatchMode=yes`, и при неудаче — печать ручной подсказки с содержимым `.pub`.
27
+ - **Запись инструкций агенту** — managed-блок с картой серверов и готовыми примерами
28
+ `ssh` / `scp` / `rsync`, значения берутся из `.claude/.env`.
29
+ - **Skill `aif-ssh`** — копируется в `.claude/skills/` проекта и учит агента пользоваться
30
+ настроенным доступом.
31
+ - **Идемпотентность** — повторный запуск не дублирует ключи, env-записи и managed-блок.
32
+
33
+ > `update` и `doctor` объявлены в `--help`, но пока не реализованы (заглушки).
34
+
35
+ ## Команды
36
+
37
+ | Команда | Что делает |
38
+ |---------|------------|
39
+ | `seomi-ssh init` | Интерактивная настройка (см. выше) |
40
+ | `seomi-ssh init --dry-run` | Прогон опроса без записи на диск и SSH-вызовов; превью блока |
41
+ | `seomi-ssh init --verbose` | Включает debug-логирование |
42
+ | `seomi-ssh --help` | Справка |
43
+ | `seomi-ssh --version` | Версия |
44
+ | `seomi-ssh update` / `doctor` | Зарезервированы (пока не реализованы) |
45
+
46
+ ## Требования
47
+
48
+ - **Node 20+** для самого CLI.
49
+ - **OpenSSH client** (`ssh`, `ssh-keygen`) на локальной машине. `ssh-copy-id` — опционально:
50
+ без него (типично для Windows OpenSSH) визард использует портируемый ssh-pipe fallback.
51
+
52
+ ## Как работает SSH-визард
53
+
54
+ Для каждого сервера — цепочка стратегий с graceful degradation:
55
+
56
+ 1. **Keygen** — генерирует `ed25519`, если ключа нет (`ssh-keygen -N ''`, пустая passphrase —
57
+ агенту нужен неинтерактивный доступ), иначе переиспользует существующий.
58
+ 2. **Copy** — `ssh-copy-id` (один раз спросит пароль). Если бинарника нет на PATH — fallback:
59
+ публичный ключ передаётся в `~/.ssh/authorized_keys` через `ssh` (с дедупликацией).
60
+ 3. **Verify** — `ssh -o BatchMode=yes ... 'echo ok'`. BatchMode отключает запрос пароля,
61
+ поэтому ненулевой код = ключ не принят.
62
+ 4. **Fallback** — при неудачной проверке печатается ручная подсказка с содержимым `.pub`.
63
+
64
+ Ошибка одного сервера не прерывает прогон — остальные продолжают.
65
+
66
+ ## Конфигурация
67
+
68
+ Параметры доступа лежат в `.claude/.env` (gitignored) плоскими ключами с **префиксом роли**.
69
+ Один сервер = одна группа ключей:
70
+
71
+ | Ключ | Назначение | Пишется |
72
+ |------|------------|---------|
73
+ | `SSH_<PREFIX>_HOST` | домен или IP | всегда |
74
+ | `SSH_<PREFIX>_USER` | SSH-пользователь | всегда |
75
+ | `SSH_<PREFIX>_PORT` | порт | если задан |
76
+ | `SSH_<PREFIX>_KEY` | путь к приватному ключу | всегда |
77
+ | `SSH_<PREFIX>_ROOT` | рабочая директория на сервере | если задана |
78
+ | `SSH_SERVERS` | csv-реестр всех префиксов | всегда |
79
+
80
+ `<PREFIX>` — это роль, нормализованная в `UPPER_SNAKE_CASE` (`prod` → `PROD`, `staging-eu` →
81
+ `STAGING_EU`); при повторе роли префикс получает уникальный суффикс (`PROD`, затем `PROD_2`).
82
+ `.claude/.env` — **первое место**, куда агент смотрит за реквизитами доступа, а не `~/.ssh/config`.
83
+
84
+ ## Пример
85
+
86
+ ```bash
87
+ $ seomi-ssh init
88
+ › Шаг 1: Опрос серверов
89
+ ? Роль сервера › prod
90
+ ? [prod] Host (домен или IP) › prod.example.com
91
+ ? [prod] SSH-пользователь › ai-agent
92
+ ? Добавить ещё один сервер? › No
93
+ › Шаг 2: Настройка SSH-ключей
94
+ [ok] «prod»: ключ настроен и проверен
95
+ › Шаг 4: Инструкции агенту (AGENTS.md / CLAUDE.md)
96
+ [ok] created: AGENTS.md
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Документация
102
+
103
+ | Раздел | Описание |
104
+ |--------|----------|
105
+ | [Команда `init`](https://github.com/Mikeekb/seomi-ssh/blob/main/docs/init.md) | Поведение `init`, конфигурация `.claude/.env`, примеры, диагностика |
106
+
107
+ ## Лицензия
108
+
109
+ Proprietary — © SEOMI. См. `LICENSE`.
110
+
111
+ ## Связанные проекты
112
+
113
+ - [@seomi/wp-mcp](https://github.com/Mikeekb/seomi-wp-mcp) — родственный WordPress/MCP-пакет, от которого произошёл этот.
114
+ - [ai-factory](https://github.com/lee-to/ai-factory) — спутник для AI-контекста разработки.
115
+
116
+ ---
117
+
118
+ Разработка и сопровождение — [Разработка сайтов SEOmi.ru](https://seomi.ru/).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seomi/ssh",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
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
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,7 @@
12
12
  "skills",
13
13
  "templates",
14
14
  "README.md",
15
+ "README.ru.md",
15
16
  "LICENSE"
16
17
  ],
17
18
  "engines": {
@@ -33,6 +34,14 @@
33
34
  "deploy"
34
35
  ],
35
36
  "license": "SEE LICENSE IN LICENSE",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/Mikeekb/seomi-ssh.git"
40
+ },
41
+ "homepage": "https://github.com/Mikeekb/seomi-ssh#readme",
42
+ "bugs": {
43
+ "url": "https://github.com/Mikeekb/seomi-ssh/issues"
44
+ },
36
45
  "publishConfig": {
37
46
  "access": "public"
38
47
  },
@@ -15,13 +15,14 @@
15
15
  */
16
16
 
17
17
  import { mkdir, copyFile } from 'node:fs/promises';
18
+ import { existsSync } from 'node:fs';
18
19
  import { join } from 'node:path';
19
20
  import { fileURLToPath } from 'node:url';
20
21
  import { logger } from '../lib/logger.mjs';
21
22
  import { promptServers, toEnvUpdates } from '../lib/server-prompt.mjs';
22
23
  import { mergeEnv } from '../lib/env-writer.mjs';
23
24
  import { ensureSshKey } from '../lib/ssh-key-setup.mjs';
24
- import { detectAgentMdTargets } from '../lib/agent-md-target.mjs';
25
+ import { detectAgentMdTargets, ensureClaudeImportStub, isClaudeImportStub } from '../lib/agent-md-target.mjs';
25
26
  import { renderAgentMdBlock } from '../lib/agent-md-renderer.mjs';
26
27
  import { insertOrUpdate } from '../lib/markers.mjs';
27
28
 
@@ -117,6 +118,10 @@ export async function initCommand( options = {} ) {
117
118
  if ( dryRun ) {
118
119
  const { targets } = await detectAgentMdTargets( { cwd, interactive: false } );
119
120
  logger.info( `[dry-run] managed-блок был бы записан в: ${ targets.join( ', ' ) || '(none)' }` );
121
+ const wouldStub = targets.some( ( f ) => f.endsWith( 'AGENTS.md' ) ) && ! existsSync( join( cwd, 'CLAUDE.md' ) );
122
+ if ( wouldStub ) {
123
+ logger.info( '[dry-run] был бы создан CLAUDE.md с импортом @AGENTS.md (Claude Code не читает AGENTS.md)' );
124
+ }
120
125
  process.stdout.write( '\n--- managed block preview ---\n' + block + '--- end preview ---\n' );
121
126
  } else {
122
127
  const { targets } = await detectAgentMdTargets( {
@@ -128,9 +133,26 @@ export async function initCommand( options = {} ) {
128
133
  logger.warn( 'Целевой файл инструкций не выбран — managed-блок не записан.' );
129
134
  }
130
135
  for ( const file of targets ) {
136
+ const name = file.split( /[\\/]/ ).pop();
137
+ // CLAUDE.md, который лишь импортирует AGENTS.md, — это редирект, а не
138
+ // носитель блока: писать в него блок значит снова продублировать
139
+ // контент. Оставляем его чистым импортом `@AGENTS.md`.
140
+ if ( name === 'CLAUDE.md' && isClaudeImportStub( file ) ) {
141
+ logger.info( `${ name }: оставлен как импорт @AGENTS.md (блок живёт в AGENTS.md)` );
142
+ continue;
143
+ }
131
144
  const res = await insertOrUpdate( file, block, { namespace: MARKER_NS } );
132
145
  logger.success( `${ res.action }: ${ file }` );
133
146
  }
147
+ // Claude Code не читает AGENTS.md — если блок лёг туда и CLAUDE.md нет,
148
+ // создаём однострочный CLAUDE.md с импортом, чтобы Claude Code видел те
149
+ // же инструкции (включая доступы по SSH).
150
+ if ( targets.length > 0 ) {
151
+ const stub = await ensureClaudeImportStub( { cwd } );
152
+ if ( stub.created ) {
153
+ logger.success( 'Создан CLAUDE.md (импорт @AGENTS.md) — Claude Code теперь читает AGENTS.md' );
154
+ }
155
+ }
134
156
  }
135
157
 
136
158
  // --- 6. Copy the access skill ----------------------------------------
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * Target-aware detection for AI agent instructions files (AGENTS.md / CLAUDE.md).
3
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.
4
+ * Claude Code reads `CLAUDE.md` and NEVER `AGENTS.md` not even when no
5
+ * CLAUDE.md exists next to it (see https://code.claude.com/docs/en/memory
6
+ * "AGENTS.md"). Other agents (Cursor, ai-factory tooling, etc.) treat
7
+ * `AGENTS.md` as the universal standard. To satisfy both without duplicating
8
+ * content, we keep the managed `seomi-ssh` block in `AGENTS.md` (the single
9
+ * source of truth) and drop a one-line `CLAUDE.md` that imports it via
10
+ * `@AGENTS.md` — so Claude Code reads the same instructions. See
11
+ * `ensureClaudeImportStub` below.
9
12
  *
10
13
  * Decision tree:
11
14
  * 1. Both files exist → targets = [AGENTS.md, CLAUDE.md] (source='both')
@@ -15,7 +18,8 @@
15
18
  * 5. Neither, interactive=true → select prompt → user / skipped (source='user'|'skipped')
16
19
  */
17
20
 
18
- import { existsSync } from 'node:fs';
21
+ import { existsSync, readFileSync } from 'node:fs';
22
+ import { writeFile } from 'node:fs/promises';
19
23
  import { join } from 'node:path';
20
24
  import { logger } from './logger.mjs';
21
25
 
@@ -23,6 +27,19 @@ export const DEFAULT_TARGET = 'AGENTS.md';
23
27
  const AGENTS_FILE = 'AGENTS.md';
24
28
  const CLAUDE_FILE = 'CLAUDE.md';
25
29
 
30
+ /**
31
+ * One-line `CLAUDE.md` that imports `AGENTS.md`. The HTML comment is stripped
32
+ * by Claude Code before the file enters context (it only documents the file for
33
+ * humans), so the effective payload is just the `@AGENTS.md` import directive.
34
+ */
35
+ export const CLAUDE_IMPORT_STUB = `<!--
36
+ Managed by @seomi/ssh. Claude Code loads CLAUDE.md but never AGENTS.md,
37
+ so this stub imports AGENTS.md — the agent-agnostic source of truth — keeping
38
+ Claude Code and other agents on the same instructions. Edit AGENTS.md, not this.
39
+ -->
40
+ @AGENTS.md
41
+ `;
42
+
26
43
  /**
27
44
  * Detect which agent-instructions file(s) the project uses and return absolute
28
45
  * paths to be updated with the managed `seomi-ssh` block.
@@ -105,3 +122,51 @@ export async function detectAgentMdTargets( { cwd, interactive = false, defaultN
105
122
  logger.success( `[agent-md] targets: ${ targets.join( ', ' ) }` );
106
123
  return { targets, source: 'user' };
107
124
  }
125
+
126
+ /**
127
+ * Is `claudePath` a thin import stub (it imports AGENTS.md and carries no
128
+ * managed seomi-ssh block of its own)? Lets callers tell an intended
129
+ * `@AGENTS.md` redirect apart from a real duplicated block.
130
+ *
131
+ * @param {string} claudePath — absolute path to the candidate CLAUDE.md
132
+ * @returns {boolean}
133
+ */
134
+ export function isClaudeImportStub( claudePath ) {
135
+ if ( ! existsSync( claudePath ) ) return false;
136
+ const text = readFileSync( claudePath, 'utf8' );
137
+ const importsAgents = /(^|\n)[ \t]*@AGENTS\.md[ \t]*(\r?\n|$)/.test( text );
138
+ const hasManagedBlock = /<!--\s*seomi-ssh:start\s*-->/.test( text );
139
+ return importsAgents && ! hasManagedBlock;
140
+ }
141
+
142
+ /**
143
+ * Ensure Claude Code can read the project's instructions when the managed block
144
+ * lives in `AGENTS.md`. Claude Code never reads `AGENTS.md`, so when only that
145
+ * file exists we drop a one-line `CLAUDE.md` that imports it (`@AGENTS.md`).
146
+ *
147
+ * Idempotent and non-destructive: does nothing when there is no `AGENTS.md`, or
148
+ * when a `CLAUDE.md` already exists (we never overwrite a user's CLAUDE.md).
149
+ *
150
+ * @param {object} opts
151
+ * @param {string} opts.cwd — project root
152
+ * @returns {Promise<{ created: boolean, reason: 'created'|'no-agents'|'claude-exists', path: string }>}
153
+ */
154
+ export async function ensureClaudeImportStub( { cwd } = {} ) {
155
+ if ( ! cwd ) throw new Error( 'ensureClaudeImportStub: `cwd` is required' );
156
+
157
+ const agentsPath = join( cwd, AGENTS_FILE );
158
+ const claudePath = join( cwd, CLAUDE_FILE );
159
+
160
+ if ( ! existsSync( agentsPath ) ) {
161
+ logger.debug( '[agent-md] import stub skipped — no AGENTS.md to import' );
162
+ return { created: false, reason: 'no-agents', path: claudePath };
163
+ }
164
+ if ( existsSync( claudePath ) ) {
165
+ logger.debug( '[agent-md] import stub skipped — CLAUDE.md already exists' );
166
+ return { created: false, reason: 'claude-exists', path: claudePath };
167
+ }
168
+
169
+ await writeFile( claudePath, CLAUDE_IMPORT_STUB, 'utf8' );
170
+ logger.success( '[agent-md] created CLAUDE.md (@AGENTS.md import) so Claude Code reads AGENTS.md' );
171
+ return { created: true, reason: 'created', path: claudePath };
172
+ }