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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudenv",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "One command to integrate Claude Code into any project — installs /claudenv command for AI-powered documentation generation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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) {
@@ -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])