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.
- package/README.md +93 -3
- package/bin/cli.js +294 -0
- package/package.json +1 -1
- package/scaffold/.claude/memories/README.md +44 -0
- package/scaffold/global/.claude/commands/add-source.md +31 -0
- package/scaffold/global/.claude/commands/canon.md +33 -0
- package/scaffold/global/.claude/commands/decisions.md +29 -0
- package/scaffold/global/.claude/commands/deeper.md +55 -0
- package/scaffold/global/.claude/commands/just-code.md +26 -0
- package/scaffold/global/.claude/commands/why.md +33 -0
- package/scaffold/global/.claude/skills/source-connector/SKILL.md +128 -0
- package/scaffold/global/.claude/skills/vibe-decisions/SKILL.md +127 -0
- package/scaffold/global/.claude/skills/vibe-decisions/deep-dive-template.md +41 -0
- package/scaffold/global-claudenv/config.yaml +16 -0
- package/scaffold/global-claudenv/memories/INDEX.md +25 -0
- package/scaffold/global-claudenv/memories/canon/index.yaml +21 -0
- package/scaffold/global-claudenv/memories/user/preferences.md.example +16 -0
- package/src/autonomy.js +6 -1
- package/src/canon.js +214 -0
- package/src/decisions.js +161 -0
- package/src/doctor.js +201 -0
- package/src/hooks/decisions-logger.js +183 -0
- package/src/hooks/dispatcher.js +91 -0
- package/src/hooks/regen-index.js +198 -0
- package/src/hooks-gen.js +43 -1
- package/src/installer.js +57 -14
- package/src/loop.js +26 -1
- package/src/memory-context.js +63 -0
- package/src/memory-paths.js +128 -0
- package/src/memory.js +111 -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/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])
|