claudenv 1.3.0 → 1.3.1
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/bin/cli.js +102 -0
- package/package.json +1 -1
- package/scaffold/global/.claude/commands/add-source.md +31 -0
- package/scaffold/global/.claude/skills/source-connector/SKILL.md +128 -0
- package/src/doctor.js +26 -0
- package/src/installer.js +2 -0
- package/src/memory-paths.js +42 -0
- package/src/sources.js +78 -0
- package/src/workspaces.js +129 -0
- package/templates/connector-mssql.ejs +62 -0
- package/templates/connector-rest.ejs +59 -0
package/bin/cli.js
CHANGED
|
@@ -469,6 +469,108 @@ canonCmd
|
|
|
469
469
|
}
|
|
470
470
|
});
|
|
471
471
|
|
|
472
|
+
// =============================================
|
|
473
|
+
// Workspaces (isolated per-company/context memory)
|
|
474
|
+
// =============================================
|
|
475
|
+
const wsCmd = program.command('workspace').description('Isolated memory spaces (~/.claudenv/workspaces/)');
|
|
476
|
+
|
|
477
|
+
wsCmd
|
|
478
|
+
.command('add')
|
|
479
|
+
.argument('<id>', 'Workspace id (slug: a-z0-9-_)')
|
|
480
|
+
.option('--name <name>', 'Human-readable name')
|
|
481
|
+
.option('--desc <description>', 'Description')
|
|
482
|
+
.option('--path <path...>', 'Project path(s) bound to this workspace')
|
|
483
|
+
.action(async (id, opts) => {
|
|
484
|
+
const { createWorkspace } = await import('../src/workspaces.js');
|
|
485
|
+
try {
|
|
486
|
+
await createWorkspace(id, { name: opts.name, description: opts.desc, paths: opts.path });
|
|
487
|
+
console.log(` + workspace "${id}" created`);
|
|
488
|
+
} catch (err) {
|
|
489
|
+
console.error(err.message);
|
|
490
|
+
process.exit(2);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
wsCmd
|
|
495
|
+
.command('list')
|
|
496
|
+
.action(async () => {
|
|
497
|
+
const { listWorkspaces } = await import('../src/workspaces.js');
|
|
498
|
+
const list = await listWorkspaces();
|
|
499
|
+
if (list.length === 0) {
|
|
500
|
+
console.log(' No workspaces yet — create one with `claudenv workspace add <id>`');
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
for (const w of list) {
|
|
504
|
+
console.log(` ${w.active ? '*' : ' '} ${w.id}${w.name && w.name !== w.id ? ` — ${w.name}` : ''}`);
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
wsCmd
|
|
509
|
+
.command('use')
|
|
510
|
+
.argument('<id>', 'Workspace id to activate')
|
|
511
|
+
.action(async (id) => {
|
|
512
|
+
const { useWorkspace } = await import('../src/workspaces.js');
|
|
513
|
+
try {
|
|
514
|
+
await useWorkspace(id);
|
|
515
|
+
console.log(` active workspace → ${id}`);
|
|
516
|
+
} catch (err) {
|
|
517
|
+
console.error(err.message);
|
|
518
|
+
process.exit(2);
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
wsCmd
|
|
523
|
+
.command('show')
|
|
524
|
+
.action(async () => {
|
|
525
|
+
const { showActive } = await import('../src/workspaces.js');
|
|
526
|
+
const w = await showActive();
|
|
527
|
+
if (!w) {
|
|
528
|
+
console.log(' No active workspace (set CLAUDENV_WORKSPACE or run `claudenv workspace use <id>`)');
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
console.log(` active: ${w.id} (${w.source})`);
|
|
532
|
+
if (w.name && w.name !== w.id) console.log(` name: ${w.name}`);
|
|
533
|
+
if (w.description) console.log(` desc: ${w.description}`);
|
|
534
|
+
if (w.paths && w.paths.length) console.log(` paths: ${w.paths.join(', ')}`);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// =============================================
|
|
538
|
+
// Sources (connectors in the active workspace)
|
|
539
|
+
// =============================================
|
|
540
|
+
const srcCmd = program.command('source').description('Data-source connectors in the active workspace');
|
|
541
|
+
|
|
542
|
+
srcCmd
|
|
543
|
+
.command('list')
|
|
544
|
+
.action(async () => {
|
|
545
|
+
const { listConnectors } = await import('../src/sources.js');
|
|
546
|
+
const { workspace, connectors } = await listConnectors();
|
|
547
|
+
if (!workspace) {
|
|
548
|
+
console.log(' No active workspace — set one first (`claudenv workspace use <id>`)');
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (connectors.length === 0) {
|
|
552
|
+
console.log(` [${workspace}] no connectors yet — add one via /add-source in Claude Code`);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
console.log(` [${workspace}]`);
|
|
556
|
+
for (const c of connectors) {
|
|
557
|
+
console.log(` ${c.name} (${c.type}, ${c.status})${c.host ? ` ${c.host}` : ''}`);
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
srcCmd
|
|
562
|
+
.command('show')
|
|
563
|
+
.argument('<name>', 'Connector name')
|
|
564
|
+
.action(async (name) => {
|
|
565
|
+
const { showConnector } = await import('../src/sources.js');
|
|
566
|
+
const text = await showConnector(name);
|
|
567
|
+
if (!text) {
|
|
568
|
+
console.error(` Connector "${name}" not found in active workspace`);
|
|
569
|
+
process.exit(2);
|
|
570
|
+
}
|
|
571
|
+
console.log(text);
|
|
572
|
+
});
|
|
573
|
+
|
|
472
574
|
// --- doctor ---
|
|
473
575
|
program
|
|
474
576
|
.command('doctor')
|
package/package.json
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Добавить коннектор к источнику данных (SQL/REST/вики/Redash) с изоляцией по workspace
|
|
3
|
+
allowed-tools: Bash, Read, Write, Edit, Skill
|
|
4
|
+
argument-hint: [<описание источника>]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# /add-source - подключить источник данных
|
|
8
|
+
|
|
9
|
+
Запускает skill `source-connector`: опрашивает тебя, генерирует рабочий коннектор,
|
|
10
|
+
кладёт секреты в `.env.local`, кэширует знание о коннекторе (параметры + provenance)
|
|
11
|
+
в память активного workspace.
|
|
12
|
+
|
|
13
|
+
## Routing
|
|
14
|
+
|
|
15
|
+
1. Активируй skill `source-connector`.
|
|
16
|
+
2. Если в `$ARGUMENTS` есть описание источника (тип/хост/система) - передай его как
|
|
17
|
+
стартовый контекст, остальное доспроси.
|
|
18
|
+
3. Если аргументов нет - начни с вопроса, к какому источнику подключаемся.
|
|
19
|
+
|
|
20
|
+
## Перед стартом
|
|
21
|
+
|
|
22
|
+
- Определи активный workspace (env `CLAUDENV_WORKSPACE` -> файл
|
|
23
|
+
`~/.claudenv/active-workspace`). Нет активного - предложи выбрать/создать,
|
|
24
|
+
не пиши в глобальную память.
|
|
25
|
+
- Напомни инвариант: секреты идут только в `.env.local` (gitignored), в память -
|
|
26
|
+
только имена переменных.
|
|
27
|
+
|
|
28
|
+
## Когда НЕ создавать коннектор
|
|
29
|
+
|
|
30
|
+
Если для источника есть готовый MCP-сервер - предложи путь через `/setup-mcp`
|
|
31
|
+
(`.mcp.json`), коннектор нужен только для источников без MCP.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: source-connector
|
|
3
|
+
description: |
|
|
4
|
+
Trigger when the user wants to connect to a data source that has no ready
|
|
5
|
+
MCP server - internal SQL/DWH behind VPN, corporate wiki (Confluence),
|
|
6
|
+
Redash, YouTrack, a custom REST API - or says "добавь источник",
|
|
7
|
+
"подключись к <система>", "/add-source". The skill interviews the user,
|
|
8
|
+
generates a working connector script, stores credentials in .env.local
|
|
9
|
+
(gitignored, never in memory), and caches connector knowledge (params +
|
|
10
|
+
provenance) in the ACTIVE workspace memory, isolated from other workspaces.
|
|
11
|
+
Do NOT trigger for sources that already have an MCP server (prefer MCP),
|
|
12
|
+
or for trivial local file reads.
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# Source connector
|
|
16
|
+
|
|
17
|
+
Создаёт коннектор к источнику данных без готового MCP: внутренний SQL/DWH,
|
|
18
|
+
корпоративная вики, Redash, YouTrack, самописный REST. Опрашивает юзера,
|
|
19
|
+
генерирует рабочий скрипт, кладёт секреты в `.env.local`, кэширует знание о
|
|
20
|
+
коннекторе (параметры + provenance) в память АКТИВНОГО workspace.
|
|
21
|
+
|
|
22
|
+
## Step 0 - Workspace resolution (FIRST, every time)
|
|
23
|
+
|
|
24
|
+
Память изолирована по пространствам (workspaces). Прежде чем что-либо писать:
|
|
25
|
+
|
|
26
|
+
1. Определи активный workspace:
|
|
27
|
+
- env `CLAUDENV_WORKSPACE`, иначе файл `~/.claudenv/active-workspace`.
|
|
28
|
+
- если есть `claudenv` CLI: `claudenv workspace show`.
|
|
29
|
+
2. Если активного нет - предложи выбрать из `~/.claudenv/workspaces/` или создать
|
|
30
|
+
новый. НИКОГДА не подтягивай и не пиши в «глобальную» память доступы/коннекторы.
|
|
31
|
+
3. Все записи коннектора пиши ТОЛЬКО в активный workspace:
|
|
32
|
+
`~/.claudenv/workspaces/<id>/memories/connectors/`.
|
|
33
|
+
4. НИКОГДА не читай чужие workspaces - это барьер от ликов между компаниями.
|
|
34
|
+
|
|
35
|
+
## Security invariants (нарушать нельзя)
|
|
36
|
+
|
|
37
|
+
- **Секреты (пароли, токены, ключи) - только в `<project>/.env.local`** и только
|
|
38
|
+
значениями там. Убедись, что `.env.local` есть в `.gitignore` (добавь, если нет).
|
|
39
|
+
- **В память коннектора - НИКОГДА значения секретов.** Только `secret_refs` -
|
|
40
|
+
имена переменных из `.env.local`.
|
|
41
|
+
- **Не эхай секреты** в вывод/логи/коммиты.
|
|
42
|
+
- Память активного workspace изолирована: не смешивай контекст разных компаний.
|
|
43
|
+
|
|
44
|
+
## Trigger criteria
|
|
45
|
+
|
|
46
|
+
Триггерь, если юзеру нужно регулярно/программно ходить в источник:
|
|
47
|
+
- БД (MSSQL/Postgres/ClickHouse), DWH за VPN
|
|
48
|
+
- корпоративная вики (Confluence), Redash, YouTrack, Grafana
|
|
49
|
+
- самописный REST/GraphQL API
|
|
50
|
+
|
|
51
|
+
НЕ триггерь:
|
|
52
|
+
- если для источника есть MCP-сервер - предложи MCP (см. ниже)
|
|
53
|
+
- разовое чтение локального файла
|
|
54
|
+
- источник уже подключён (тогда это обновление, см. Step 6)
|
|
55
|
+
|
|
56
|
+
## Step 1 - MCP vs connector
|
|
57
|
+
|
|
58
|
+
Сначала проверь, нет ли готового MCP для источника (claudenv умеет настраивать MCP
|
|
59
|
+
в `.mcp.json`). Если есть - предложи MCP-путь. Коннектор - для источников БЕЗ MCP.
|
|
60
|
+
|
|
61
|
+
## Step 2 - Interview (спроси у юзера, кратко)
|
|
62
|
+
|
|
63
|
+
Собери минимум для подключения:
|
|
64
|
+
- тип источника (mssql / postgres / clickhouse / rest / confluence / redash / youtrack)
|
|
65
|
+
- хост и порт, база/endpoint
|
|
66
|
+
- метод аутентификации (sql login / domain / token / trusted)
|
|
67
|
+
- что нужно достать (таблицы/эндпоинты) - для первого запроса
|
|
68
|
+
- как зовутся переменные кред в `.env.local` (или предложи имена)
|
|
69
|
+
|
|
70
|
+
Не выдумывай параметры - спрашивай. Если юзер уже дал часть (вики/git/переписка) -
|
|
71
|
+
зафиксируй это в provenance.
|
|
72
|
+
|
|
73
|
+
## Step 3 - Generate connector script
|
|
74
|
+
|
|
75
|
+
Сгенерируй рабочий скрипт из шаблона (`templates/connector-*.ejs`):
|
|
76
|
+
- MSSQL -> pymssql/pyodbc, строка `mssql+pymssql://{user}:{pwd}@{host}:{port}/{db}`
|
|
77
|
+
(СУЗ - логин без домена; экранируй пароль через urllib `quote_plus`).
|
|
78
|
+
- REST -> requests + Bearer-токен из env.
|
|
79
|
+
Скрипт читает креды из `.env.local` (через env), не хардкодит.
|
|
80
|
+
|
|
81
|
+
## Step 4 - Secrets to .env.local
|
|
82
|
+
|
|
83
|
+
- Запиши значения в `<project>/.env.local` (создай при отсутствии).
|
|
84
|
+
- Проверь/добавь `.env.local` в `.gitignore`.
|
|
85
|
+
- В память пойдут только имена этих переменных (`secret_refs`).
|
|
86
|
+
|
|
87
|
+
## Step 5 - Write connector record (в активный workspace)
|
|
88
|
+
|
|
89
|
+
Создай `~/.claudenv/workspaces/<id>/memories/connectors/<name>.md`:
|
|
90
|
+
|
|
91
|
+
```markdown
|
|
92
|
+
---
|
|
93
|
+
name: <name>
|
|
94
|
+
type: <mssql|rest|...>
|
|
95
|
+
status: <draft|working|blocked>
|
|
96
|
+
host: <host>
|
|
97
|
+
database: <db|->
|
|
98
|
+
port: <port|->
|
|
99
|
+
auth: <sql|domain|token|trusted>
|
|
100
|
+
secret_refs: {user: <ENV_VAR>, password: <ENV_VAR>} # ИМЕНА, не значения
|
|
101
|
+
connector_script: <path в проекте>
|
|
102
|
+
provenance: # откуда собрана инфа
|
|
103
|
+
- {source: <wiki|git|chat|docs>, ref: <url|путь>, note: <кратко>}
|
|
104
|
+
verified_at: <YYYY-MM-DD|->
|
|
105
|
+
updated_at: <YYYY-MM-DD>
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
Детали: как устроено подключение, что блокирует, как обновлять.
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Provenance обязателен** - фиксируй, откуда взял каждый факт (вики-страница, файл
|
|
112
|
+
в git, сообщение в чате). Это позволяет потом перепроверить и обновить.
|
|
113
|
+
|
|
114
|
+
## Step 6 - Test and iterate
|
|
115
|
+
|
|
116
|
+
Протестируй подключение, если возможно. Зафиксируй `status` и `verified_at`.
|
|
117
|
+
При ошибке - запиши симптом в запись (раздел деталей) и предложи следующий шаг.
|
|
118
|
+
|
|
119
|
+
## Step 7 - Updating an existing connector
|
|
120
|
+
|
|
121
|
+
Если коннектор уже есть в активном workspace:
|
|
122
|
+
- найди запись, обнови `status`/`host`/`secret_refs`, допиши `provenance`,
|
|
123
|
+
обнови `updated_at`. Запись - единый источник правды по коннектору.
|
|
124
|
+
|
|
125
|
+
## После создания
|
|
126
|
+
|
|
127
|
+
Предложи дальнейшую автоматизацию через коннектор (регулярная выгрузка, отчёт,
|
|
128
|
+
интеграция в пайплайн). Язык общения - русский.
|
package/src/doctor.js
CHANGED
|
@@ -13,7 +13,10 @@ import {
|
|
|
13
13
|
memoriesDir,
|
|
14
14
|
globalDecisionsDir,
|
|
15
15
|
indexMdPath,
|
|
16
|
+
activeWorkspaceId,
|
|
17
|
+
workspaceConnectorsDir,
|
|
16
18
|
} from './memory-paths.js';
|
|
19
|
+
import { scanForSecretLeaks } from './sources.js';
|
|
17
20
|
|
|
18
21
|
const OK = '[OK] ';
|
|
19
22
|
const WARN = '[WARN]';
|
|
@@ -151,6 +154,28 @@ async function countDecisionsCheck() {
|
|
|
151
154
|
return { status: OK, msg: `${count} global decisions logged` };
|
|
152
155
|
}
|
|
153
156
|
|
|
157
|
+
async function workspaceLeakCheck() {
|
|
158
|
+
const id = activeWorkspaceId();
|
|
159
|
+
if (!id) return { status: OK, msg: 'no active workspace — secret-leak scan skipped' };
|
|
160
|
+
let files;
|
|
161
|
+
try {
|
|
162
|
+
files = (await readdir(workspaceConnectorsDir(id))).filter((f) => f.endsWith('.md'));
|
|
163
|
+
} catch {
|
|
164
|
+
return { status: OK, msg: `workspace "${id}": no connectors to scan` };
|
|
165
|
+
}
|
|
166
|
+
const leaks = [];
|
|
167
|
+
for (const f of files) {
|
|
168
|
+
try {
|
|
169
|
+
const found = scanForSecretLeaks(await readFile(join(workspaceConnectorsDir(id), f), 'utf-8'));
|
|
170
|
+
if (found.length) leaks.push(`${f}:${found[0].line}`);
|
|
171
|
+
} catch { /* skip */ }
|
|
172
|
+
}
|
|
173
|
+
if (leaks.length) {
|
|
174
|
+
return { status: FAIL, msg: `secret values in workspace memory: ${leaks.join(', ')} — move to .env.local` };
|
|
175
|
+
}
|
|
176
|
+
return { status: OK, msg: `workspace "${id}": connector memory clean (no secret values)` };
|
|
177
|
+
}
|
|
178
|
+
|
|
154
179
|
export async function runDoctor() {
|
|
155
180
|
const checks = [
|
|
156
181
|
await nodeVersionCheck(),
|
|
@@ -162,6 +187,7 @@ export async function runDoctor() {
|
|
|
162
187
|
await projectHooksCheck(),
|
|
163
188
|
await pythonModuleCheck(),
|
|
164
189
|
await countDecisionsCheck(),
|
|
190
|
+
await workspaceLeakCheck(),
|
|
165
191
|
];
|
|
166
192
|
|
|
167
193
|
let hasFail = false;
|
package/src/installer.js
CHANGED
|
@@ -135,8 +135,10 @@ export async function uninstallGlobal(options = {}) {
|
|
|
135
135
|
join(targetBase, 'commands', 'decisions.md'),
|
|
136
136
|
join(targetBase, 'commands', 'canon.md'),
|
|
137
137
|
join(targetBase, 'commands', 'just-code.md'),
|
|
138
|
+
join(targetBase, 'commands', 'add-source.md'),
|
|
138
139
|
join(targetBase, 'skills', 'claudenv'),
|
|
139
140
|
join(targetBase, 'skills', 'vibe-decisions'),
|
|
141
|
+
join(targetBase, 'skills', 'source-connector'),
|
|
140
142
|
];
|
|
141
143
|
|
|
142
144
|
for (const target of targets) {
|
package/src/memory-paths.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
11
12
|
|
|
12
13
|
export function claudenvHome() {
|
|
13
14
|
return process.env.CLAUDENV_HOME || join(homedir(), '.claudenv');
|
|
@@ -41,6 +42,47 @@ export function projectDecisionsDir(cwd) {
|
|
|
41
42
|
return join(cwd, '.claude', 'memories', 'decisions');
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
// --- Workspace layer (isolated per-company/context memory) ---
|
|
46
|
+
|
|
47
|
+
export function workspacesDir() {
|
|
48
|
+
return join(claudenvHome(), 'workspaces');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function activeWorkspaceFile() {
|
|
52
|
+
return join(claudenvHome(), 'active-workspace');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function workspaceDir(id) {
|
|
56
|
+
return join(workspacesDir(), id);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function workspaceManifestPath(id) {
|
|
60
|
+
return join(workspaceDir(id), 'workspace.yaml');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function workspaceConnectorsDir(id) {
|
|
64
|
+
return join(workspaceDir(id), 'memories', 'connectors');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function workspaceContextDir(id) {
|
|
68
|
+
return join(workspaceDir(id), 'memories', 'context');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the active workspace id. Priority: env CLAUDENV_WORKSPACE, then the
|
|
73
|
+
* pointer file ~/.claudenv/active-workspace. Returns null if none set.
|
|
74
|
+
* Intentionally does NOT scan all workspaces - isolation barrier.
|
|
75
|
+
*/
|
|
76
|
+
export function activeWorkspaceId() {
|
|
77
|
+
if (process.env.CLAUDENV_WORKSPACE) return process.env.CLAUDENV_WORKSPACE.trim();
|
|
78
|
+
try {
|
|
79
|
+
const id = readFileSync(activeWorkspaceFile(), 'utf-8').trim();
|
|
80
|
+
return id || null;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
44
86
|
/**
|
|
45
87
|
* Minimal YAML frontmatter parser. Returns null when no frontmatter is present.
|
|
46
88
|
* Handles `key: value` and inline arrays `[a, b, c]`. Heavier structures fall
|
package/src/sources.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI: `claudenv source list|show`
|
|
3
|
+
*
|
|
4
|
+
* Connectors are knowledge records (params + provenance) stored in the ACTIVE
|
|
5
|
+
* workspace: ~/.claudenv/workspaces/<id>/memories/connectors/<name>.md.
|
|
6
|
+
*
|
|
7
|
+
* Records hold connection metadata and secret_refs (env-var NAMES) only.
|
|
8
|
+
* Actual secret values live in <project>/.env.local and must never appear here.
|
|
9
|
+
* The skill `source-connector` is the primary author; CLI is for listing and
|
|
10
|
+
* for the doctor leak-lint.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { workspaceConnectorsDir, activeWorkspaceId, parseFrontmatter } from './memory-paths.js';
|
|
16
|
+
|
|
17
|
+
export async function listConnectors() {
|
|
18
|
+
const id = activeWorkspaceId();
|
|
19
|
+
if (!id) return { workspace: null, connectors: [] };
|
|
20
|
+
let files;
|
|
21
|
+
try {
|
|
22
|
+
files = (await readdir(workspaceConnectorsDir(id))).filter((f) => f.endsWith('.md'));
|
|
23
|
+
} catch {
|
|
24
|
+
return { workspace: id, connectors: [] };
|
|
25
|
+
}
|
|
26
|
+
const connectors = [];
|
|
27
|
+
for (const f of files) {
|
|
28
|
+
let fm = null;
|
|
29
|
+
try {
|
|
30
|
+
fm = parseFrontmatter(await readFile(join(workspaceConnectorsDir(id), f), 'utf-8'));
|
|
31
|
+
} catch { /* skip unreadable */ }
|
|
32
|
+
connectors.push({
|
|
33
|
+
name: (fm && fm.name) || f.replace(/\.md$/, ''),
|
|
34
|
+
type: (fm && fm.type) || '?',
|
|
35
|
+
status: (fm && fm.status) || '?',
|
|
36
|
+
host: (fm && fm.host) || '',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return { workspace: id, connectors };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function showConnector(name) {
|
|
43
|
+
const id = activeWorkspaceId();
|
|
44
|
+
if (!id) return null;
|
|
45
|
+
try {
|
|
46
|
+
return await readFile(join(workspaceConnectorsDir(id), `${name}.md`), 'utf-8');
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Heuristic leak scan for the doctor. Flags lines that look like a secret VALUE
|
|
54
|
+
* rather than a reference. Connector records should only carry env-var names.
|
|
55
|
+
*
|
|
56
|
+
* Returns an array of { line, reason } findings (empty = clean).
|
|
57
|
+
*/
|
|
58
|
+
export function scanForSecretLeaks(text) {
|
|
59
|
+
const findings = [];
|
|
60
|
+
const lines = text.split('\n');
|
|
61
|
+
for (let i = 0; i < lines.length; i++) {
|
|
62
|
+
const line = lines[i];
|
|
63
|
+
// secret_refs хранит ИМЕНА env-переменных по определению - не сканируем
|
|
64
|
+
if (/secret_refs/i.test(line)) continue;
|
|
65
|
+
// `password: <value>` / `token: <value>` with a non-placeholder, non-env value
|
|
66
|
+
const m = /\b(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key)\b\s*[:=]\s*(\S+)/i.exec(line);
|
|
67
|
+
if (m) {
|
|
68
|
+
// снять кавычки и хвостовую пунктуацию inline-структур ({ } , ; )
|
|
69
|
+
const val = m[2].replace(/['"]/g, '').replace(/[},;)]+$/, '');
|
|
70
|
+
const placeholder = /^(<.*>|\.\.\.|x+|\*+|null|none|env:|\$\{|secret_refs)/i.test(val);
|
|
71
|
+
const looksEnvName = /^[A-Z][A-Z0-9_]*$/.test(val) || /_(env|token|login|password)$/i.test(val);
|
|
72
|
+
if (!placeholder && !looksEnvName && val.length >= 6) {
|
|
73
|
+
findings.push({ line: i + 1, reason: `похоже на значение секрета: ${m[1]}` });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return findings;
|
|
78
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI: `claudenv workspace add|list|use|show`
|
|
3
|
+
*
|
|
4
|
+
* Workspaces are isolated per-company/context memory spaces under
|
|
5
|
+
* ~/.claudenv/workspaces/<id>/. Only the ACTIVE workspace is ever loaded -
|
|
6
|
+
* this is the isolation barrier that prevents access/context from one company
|
|
7
|
+
* leaking into another project on the same device.
|
|
8
|
+
*
|
|
9
|
+
* Secrets NEVER live here - they belong in <project>/.env.local (gitignored).
|
|
10
|
+
* Workspace memory holds connector metadata + provenance only.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFile, writeFile, mkdir, readdir, stat } from 'node:fs/promises';
|
|
14
|
+
import {
|
|
15
|
+
workspacesDir,
|
|
16
|
+
workspaceDir,
|
|
17
|
+
workspaceManifestPath,
|
|
18
|
+
workspaceConnectorsDir,
|
|
19
|
+
workspaceContextDir,
|
|
20
|
+
activeWorkspaceFile,
|
|
21
|
+
activeWorkspaceId,
|
|
22
|
+
} from './memory-paths.js';
|
|
23
|
+
|
|
24
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
|
25
|
+
|
|
26
|
+
export function validateId(id) {
|
|
27
|
+
if (!id || !SLUG_RE.test(id)) {
|
|
28
|
+
throw new Error(`Invalid workspace id "${id}" - use lowercase letters, digits, - or _`);
|
|
29
|
+
}
|
|
30
|
+
return id;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Render a flat workspace.yaml. Minimal by design (human-editable), like canon.
|
|
35
|
+
*/
|
|
36
|
+
function renderManifest({ name, description, paths }) {
|
|
37
|
+
const lines = [];
|
|
38
|
+
lines.push(`name: ${name || ''}`);
|
|
39
|
+
lines.push(`description: ${description || ''}`);
|
|
40
|
+
lines.push('paths:');
|
|
41
|
+
for (const p of paths || []) lines.push(` - ${p}`);
|
|
42
|
+
return lines.join('\n') + '\n';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Tolerant parser for the flat workspace.yaml above. */
|
|
46
|
+
function parseManifest(text) {
|
|
47
|
+
const out = { name: '', description: '', paths: [] };
|
|
48
|
+
let inPaths = false;
|
|
49
|
+
for (const raw of text.split('\n')) {
|
|
50
|
+
const line = raw.replace(/\s+$/, '');
|
|
51
|
+
if (!line || line.startsWith('#')) continue;
|
|
52
|
+
const kv = /^([a-zA-Z_]+):\s*(.*)$/.exec(line);
|
|
53
|
+
if (kv && kv[1] === 'paths') { inPaths = true; continue; }
|
|
54
|
+
if (kv) {
|
|
55
|
+
inPaths = false;
|
|
56
|
+
if (kv[1] === 'name') out.name = kv[2];
|
|
57
|
+
else if (kv[1] === 'description') out.description = kv[2];
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const item = /^\s+-\s+(.*)$/.exec(line);
|
|
61
|
+
if (item && inPaths) out.paths.push(item[1].trim());
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function createWorkspace(id, { name, description, paths } = {}) {
|
|
67
|
+
validateId(id);
|
|
68
|
+
const dir = workspaceDir(id);
|
|
69
|
+
try {
|
|
70
|
+
await stat(dir);
|
|
71
|
+
throw new Error(`Workspace "${id}" already exists`);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if (e.code !== 'ENOENT') throw e;
|
|
74
|
+
}
|
|
75
|
+
await mkdir(workspaceConnectorsDir(id), { recursive: true });
|
|
76
|
+
await mkdir(workspaceContextDir(id), { recursive: true });
|
|
77
|
+
await writeFile(
|
|
78
|
+
workspaceManifestPath(id),
|
|
79
|
+
renderManifest({ name: name || id, description, paths }),
|
|
80
|
+
'utf-8'
|
|
81
|
+
);
|
|
82
|
+
// секреты сюда не кладём - явный gitignore на случай, если папку положат в git
|
|
83
|
+
await writeFile(workspaceDir(id) + '/.gitignore', '.env\n.env.local\n*.secret\n', 'utf-8');
|
|
84
|
+
return dir;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function listWorkspaces() {
|
|
88
|
+
let entries;
|
|
89
|
+
try {
|
|
90
|
+
entries = await readdir(workspacesDir(), { withFileTypes: true });
|
|
91
|
+
} catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
const active = activeWorkspaceId();
|
|
95
|
+
const result = [];
|
|
96
|
+
for (const ent of entries) {
|
|
97
|
+
if (!ent.isDirectory()) continue;
|
|
98
|
+
let manifest = { name: ent.name, description: '', paths: [] };
|
|
99
|
+
try {
|
|
100
|
+
manifest = parseManifest(await readFile(workspaceManifestPath(ent.name), 'utf-8'));
|
|
101
|
+
} catch { /* нет манифеста - покажем по id */ }
|
|
102
|
+
result.push({ id: ent.name, active: ent.name === active, ...manifest });
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function useWorkspace(id) {
|
|
108
|
+
validateId(id);
|
|
109
|
+
try {
|
|
110
|
+
await stat(workspaceDir(id));
|
|
111
|
+
} catch {
|
|
112
|
+
throw new Error(`Workspace "${id}" does not exist - create it with 'claudenv workspace add ${id}'`);
|
|
113
|
+
}
|
|
114
|
+
await writeFile(activeWorkspaceFile(), id + '\n', 'utf-8');
|
|
115
|
+
return id;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function showActive() {
|
|
119
|
+
const id = activeWorkspaceId();
|
|
120
|
+
if (!id) return null;
|
|
121
|
+
let manifest = { name: id, description: '', paths: [] };
|
|
122
|
+
try {
|
|
123
|
+
manifest = parseManifest(await readFile(workspaceManifestPath(id), 'utf-8'));
|
|
124
|
+
} catch { /* активный указан, но манифеста нет */ }
|
|
125
|
+
const source = process.env.CLAUDENV_WORKSPACE ? 'env CLAUDENV_WORKSPACE' : 'active-workspace file';
|
|
126
|
+
return { id, source, ...manifest };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export { parseManifest };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Коннектор к MSSQL: <%= name %> (host <%= host %>, db <%= database %>).
|
|
5
|
+
Сгенерирован claudenv (skill source-connector).
|
|
6
|
+
|
|
7
|
+
Креды читаются из .env.local (gitignored), НЕ хардкодятся:
|
|
8
|
+
<%= userEnv %>=<логин>
|
|
9
|
+
<%= passwordEnv %>=<пароль>
|
|
10
|
+
|
|
11
|
+
Зависимости: pip install pymssql sqlalchemy pandas
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from urllib.parse import quote_plus
|
|
17
|
+
|
|
18
|
+
import pandas as pd
|
|
19
|
+
from sqlalchemy import create_engine
|
|
20
|
+
|
|
21
|
+
HOST = os.getenv("<%= name.toUpperCase().replace(/[^A-Z0-9]/g, '_') %>_HOST", "<%= host %>")
|
|
22
|
+
PORT = os.getenv("<%= name.toUpperCase().replace(/[^A-Z0-9]/g, '_') %>_PORT", "<%= port || 1433 %>")
|
|
23
|
+
DATABASE = os.getenv("<%= name.toUpperCase().replace(/[^A-Z0-9]/g, '_') %>_DB", "<%= database %>")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_env_local(path=".env.local"):
|
|
27
|
+
env = {}
|
|
28
|
+
if os.path.exists(path):
|
|
29
|
+
with open(path, encoding="utf-8") as fh:
|
|
30
|
+
for line in fh:
|
|
31
|
+
line = line.strip()
|
|
32
|
+
if line and not line.startswith("#") and "=" in line:
|
|
33
|
+
k, _, v = line.partition("=")
|
|
34
|
+
env[k.strip()] = v.strip()
|
|
35
|
+
return env
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_env = load_env_local()
|
|
39
|
+
USER = _env.get("<%= userEnv %>") or os.getenv("<%= userEnv %>")
|
|
40
|
+
PASSWORD = _env.get("<%= passwordEnv %>") or os.getenv("<%= passwordEnv %>")
|
|
41
|
+
|
|
42
|
+
if not USER or not PASSWORD:
|
|
43
|
+
sys.exit("Нет кред в .env.local: <%= userEnv %> / <%= passwordEnv %>")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def engine():
|
|
47
|
+
# пароль экранируем - в нём могут быть спецсимволы (@ : ~ и т.п.)
|
|
48
|
+
url = f"mssql+pymssql://{quote_plus(USER)}:{quote_plus(PASSWORD)}@{HOST}:{PORT}/{DATABASE}"
|
|
49
|
+
return create_engine(url)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def query(sql):
|
|
53
|
+
"""Выполнить SELECT, вернуть DataFrame."""
|
|
54
|
+
return pd.read_sql(sql, engine())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
sql = sys.argv[1] if len(sys.argv) > 1 else "SELECT 1 AS ok"
|
|
59
|
+
df = query(sql)
|
|
60
|
+
print(f"Строк: {len(df)}")
|
|
61
|
+
with pd.option_context("display.max_columns", None, "display.width", 200):
|
|
62
|
+
print(df.head(20).to_string(index=False))
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Коннектор к REST API: <%= name %> (base <%= host %>).
|
|
5
|
+
Сгенерирован claudenv (skill source-connector).
|
|
6
|
+
|
|
7
|
+
Токен читается из .env.local (gitignored), НЕ хардкодится:
|
|
8
|
+
<%= tokenEnv %>=<токен>
|
|
9
|
+
|
|
10
|
+
Зависимости: pip install requests
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
BASE = os.getenv("<%= name.toUpperCase().replace(/[^A-Z0-9]/g, '_') %>_BASE", "<%= host %>")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_env_local(path=".env.local"):
|
|
22
|
+
env = {}
|
|
23
|
+
if os.path.exists(path):
|
|
24
|
+
with open(path, encoding="utf-8") as fh:
|
|
25
|
+
for line in fh:
|
|
26
|
+
line = line.strip()
|
|
27
|
+
if line and not line.startswith("#") and "=" in line:
|
|
28
|
+
k, _, v = line.partition("=")
|
|
29
|
+
env[k.strip()] = v.strip()
|
|
30
|
+
return env
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_env = load_env_local()
|
|
34
|
+
TOKEN = _env.get("<%= tokenEnv %>") or os.getenv("<%= tokenEnv %>")
|
|
35
|
+
|
|
36
|
+
if not TOKEN:
|
|
37
|
+
sys.exit("Нет токена в .env.local: <%= tokenEnv %>")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def client():
|
|
41
|
+
s = requests.Session()
|
|
42
|
+
s.headers.update({
|
|
43
|
+
"Authorization": f"Bearer {TOKEN}",
|
|
44
|
+
"Accept": "application/json",
|
|
45
|
+
})
|
|
46
|
+
return s
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get(path, **params):
|
|
50
|
+
"""GET к API, вернуть JSON. path - относительный (/api/...)."""
|
|
51
|
+
r = client().get(f"{BASE}{path}", params=params, timeout=30)
|
|
52
|
+
r.raise_for_status()
|
|
53
|
+
return r.json()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
path = sys.argv[1] if len(sys.argv) > 1 else "/"
|
|
58
|
+
import json
|
|
59
|
+
print(json.dumps(get(path), ensure_ascii=False, indent=2)[:2000])
|