claudenv 1.2.5 → 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.
Files changed (34) hide show
  1. package/README.md +93 -3
  2. package/bin/cli.js +294 -0
  3. package/package.json +1 -1
  4. package/scaffold/.claude/memories/README.md +44 -0
  5. package/scaffold/global/.claude/commands/add-source.md +31 -0
  6. package/scaffold/global/.claude/commands/canon.md +33 -0
  7. package/scaffold/global/.claude/commands/decisions.md +29 -0
  8. package/scaffold/global/.claude/commands/deeper.md +55 -0
  9. package/scaffold/global/.claude/commands/just-code.md +26 -0
  10. package/scaffold/global/.claude/commands/why.md +33 -0
  11. package/scaffold/global/.claude/skills/source-connector/SKILL.md +128 -0
  12. package/scaffold/global/.claude/skills/vibe-decisions/SKILL.md +127 -0
  13. package/scaffold/global/.claude/skills/vibe-decisions/deep-dive-template.md +41 -0
  14. package/scaffold/global-claudenv/config.yaml +16 -0
  15. package/scaffold/global-claudenv/memories/INDEX.md +25 -0
  16. package/scaffold/global-claudenv/memories/canon/index.yaml +21 -0
  17. package/scaffold/global-claudenv/memories/user/preferences.md.example +16 -0
  18. package/src/autonomy.js +6 -1
  19. package/src/canon.js +214 -0
  20. package/src/decisions.js +161 -0
  21. package/src/doctor.js +201 -0
  22. package/src/hooks/decisions-logger.js +183 -0
  23. package/src/hooks/dispatcher.js +91 -0
  24. package/src/hooks/regen-index.js +198 -0
  25. package/src/hooks-gen.js +43 -1
  26. package/src/installer.js +57 -14
  27. package/src/loop.js +26 -1
  28. package/src/memory-context.js +63 -0
  29. package/src/memory-paths.js +128 -0
  30. package/src/memory.js +111 -0
  31. package/src/sources.js +78 -0
  32. package/src/workspaces.js +129 -0
  33. package/templates/connector-mssql.ejs +62 -0
  34. package/templates/connector-rest.ejs +59 -0
package/src/memory.js ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * CLI: `claudenv memory init/index/show/edit`
3
+ *
4
+ * Owns the user-facing surface for the global ~/.claudenv/memories/ layout.
5
+ * Reuses regen-index for `index` so there's one canonical regeneration path.
6
+ */
7
+
8
+ import { mkdir, readFile, writeFile, stat } from 'node:fs/promises';
9
+ import { join, dirname, resolve } from 'node:path';
10
+ import { spawnSync } from 'node:child_process';
11
+ import {
12
+ claudenvHome,
13
+ memoriesDir,
14
+ globalDecisionsDir,
15
+ canonIndexPath,
16
+ userPrefsPath,
17
+ indexMdPath,
18
+ } from './memory-paths.js';
19
+ import { regenIndex } from './hooks/regen-index.js';
20
+
21
+ /**
22
+ * `claudenv memory init` — create the global memory layout if absent.
23
+ * Idempotent; never overwrites existing files.
24
+ */
25
+ export async function memoryInit() {
26
+ const created = [];
27
+ const skipped = [];
28
+
29
+ // Layout
30
+ const dirs = [
31
+ memoriesDir(),
32
+ globalDecisionsDir(),
33
+ join(memoriesDir(), 'canon'),
34
+ join(memoriesDir(), 'user'),
35
+ ];
36
+ for (const d of dirs) {
37
+ await mkdir(d, { recursive: true });
38
+ }
39
+
40
+ // Seed files
41
+ const seeds = [
42
+ {
43
+ path: indexMdPath(),
44
+ content:
45
+ '# claudenv memory — INDEX\n\n> Auto-generated by `claudenv memory index`. Hand-edits will be overwritten.\n\n## Recent decisions\n\n_No decisions yet — vibe-decisions will log here._\n',
46
+ },
47
+ {
48
+ path: canonIndexPath(),
49
+ content:
50
+ '# Личный канон. Добавляйте через: claudenv canon add <topic> <url> --why "<reason>"\n',
51
+ },
52
+ {
53
+ path: join(claudenvHome(), '.gitignore'),
54
+ content: 'sessions/\n*.log\n.log/\nkeys/private*\n*.local.*\n*.secret.*\n',
55
+ },
56
+ ];
57
+
58
+ for (const { path, content } of seeds) {
59
+ try {
60
+ await stat(path);
61
+ skipped.push(path);
62
+ } catch {
63
+ await mkdir(dirname(path), { recursive: true });
64
+ await writeFile(path, content, 'utf-8');
65
+ created.push(path);
66
+ }
67
+ }
68
+
69
+ return { created, skipped };
70
+ }
71
+
72
+ /**
73
+ * `claudenv memory index` — regenerate INDEX.md from current decisions + prefs.
74
+ */
75
+ export async function memoryIndex({ cwd } = {}) {
76
+ return await regenIndex({ cwd: cwd || process.cwd() });
77
+ }
78
+
79
+ /**
80
+ * `claudenv memory show <relpath>` — print a file from memories/.
81
+ * Path is interpreted relative to ~/.claudenv/memories/.
82
+ */
83
+ export async function memoryShow(relPath) {
84
+ if (!relPath) throw new Error('Usage: claudenv memory show <path>');
85
+ const full = resolve(memoriesDir(), relPath);
86
+ if (!full.startsWith(memoriesDir())) {
87
+ throw new Error('Path escapes memories root');
88
+ }
89
+ return await readFile(full, 'utf-8');
90
+ }
91
+
92
+ /**
93
+ * `claudenv memory edit <relpath>` — open a memory file in $EDITOR (or vi).
94
+ * Creates the file if missing.
95
+ */
96
+ export async function memoryEdit(relPath) {
97
+ if (!relPath) throw new Error('Usage: claudenv memory edit <path>');
98
+ const full = resolve(memoriesDir(), relPath);
99
+ if (!full.startsWith(memoriesDir())) {
100
+ throw new Error('Path escapes memories root');
101
+ }
102
+ await mkdir(dirname(full), { recursive: true });
103
+ try {
104
+ await stat(full);
105
+ } catch {
106
+ await writeFile(full, '', 'utf-8');
107
+ }
108
+ const editor = process.env.EDITOR || 'vi';
109
+ const result = spawnSync(editor, [full], { stdio: 'inherit' });
110
+ return { path: full, exitCode: result.status ?? 0 };
111
+ }
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])