claudenv 1.2.5 → 1.3.0

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/src/hooks-gen.js CHANGED
@@ -4,14 +4,36 @@
4
4
 
5
5
  import { CREDENTIAL_PATHS } from './profiles.js';
6
6
 
7
+ /**
8
+ * Detect whether `claudenv` is on PATH globally. Used to decide whether
9
+ * settings.json hooks should call `claudenv hook ...` directly or via `npx`.
10
+ *
11
+ * Async — but settings generation is sync today. We call this from the
12
+ * installer flow where we await it, then pass the resolved value in.
13
+ */
14
+ export async function isGlobalInstall() {
15
+ try {
16
+ const { execFile } = await import('node:child_process');
17
+ const { promisify } = await import('node:util');
18
+ const exec = promisify(execFile);
19
+ await exec('which', ['claudenv']);
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
7
26
  /**
8
27
  * Generate .claude/settings.json content for a given profile.
9
28
  * @param {object} profile - Autonomy profile
10
29
  * @param {object} detected - Tech stack detection result
30
+ * @param {object} [opts]
31
+ * @param {string} [opts.claudenvCmd] - Command prefix for `claudenv hook ...` (e.g., "claudenv" or "npx claudenv")
11
32
  * @returns {string} JSON string
12
33
  */
13
- export function generateSettingsJson(profile, detected = {}) {
34
+ export function generateSettingsJson(profile, detected = {}, opts = {}) {
14
35
  const settings = {};
36
+ const claudenvCmd = opts.claudenvCmd || 'claudenv';
15
37
 
16
38
  if (profile.allowedTools && profile.allowedTools.length > 0) {
17
39
  settings.permissions = settings.permissions || {};
@@ -46,6 +68,16 @@ export function generateSettingsJson(profile, detected = {}) {
46
68
  command: 'bash .claude/hooks/audit-log.sh',
47
69
  timeout: 10,
48
70
  };
71
+ const decisionsLoggerHook = {
72
+ type: 'command',
73
+ command: `${claudenvCmd} hook decisions-logger`,
74
+ timeout: 5,
75
+ };
76
+ const regenIndexHook = {
77
+ type: 'command',
78
+ command: `${claudenvCmd} hook regen-index`,
79
+ timeout: 5,
80
+ };
49
81
 
50
82
  settings.hooks = {
51
83
  PreToolUse: [
@@ -71,6 +103,16 @@ export function generateSettingsJson(profile, detected = {}) {
71
103
  matcher: '',
72
104
  hooks: [auditLogHook],
73
105
  },
106
+ {
107
+ matcher: 'Write',
108
+ hooks: [decisionsLoggerHook],
109
+ },
110
+ ],
111
+ SessionEnd: [
112
+ {
113
+ matcher: '.*',
114
+ hooks: [regenIndexHook],
115
+ },
74
116
  ],
75
117
  };
76
118
 
package/src/installer.js CHANGED
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
5
5
 
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
7
  const SCAFFOLD_GLOBAL = join(__dirname, '..', 'scaffold', 'global');
8
+ const SCAFFOLD_GLOBAL_CLAUDENV = join(__dirname, '..', 'scaffold', 'global-claudenv');
8
9
 
9
10
  /**
10
11
  * Get the path to ~/.claude/
@@ -13,6 +14,14 @@ function getClaudeHome() {
13
14
  return join(homedir(), '.claude');
14
15
  }
15
16
 
17
+ /**
18
+ * Get the path to ~/.claudenv/ (separate from ~/.claude/ — holds cross-project
19
+ * memory, canon, user preferences, and the doctor config).
20
+ */
21
+ function getClaudenvHome() {
22
+ return join(homedir(), '.claudenv');
23
+ }
24
+
16
25
  /**
17
26
  * Recursively list all files in a directory (relative paths).
18
27
  */
@@ -31,20 +40,18 @@ async function listFiles(dir, base = dir) {
31
40
  }
32
41
 
33
42
  /**
34
- * Install global Claude Code artifacts to ~/.claude/.
35
- * Copies scaffold/global/.claude/ contents into ~/.claude/.
36
- *
37
- * @param {object} [options]
38
- * @param {boolean} [options.force] - Overwrite existing files
39
- * @param {string} [options.claudeHome] - Override ~/.claude/ path (for testing)
40
- * @returns {Promise<{written: string[], skipped: string[]}>}
43
+ * Copy every file under `sourceBase` into `targetBase`, skipping existing files
44
+ * unless `force` is set. Returns relative paths of what was written/skipped.
41
45
  */
42
- export async function installGlobal(options = {}) {
43
- const { force = false, claudeHome } = options;
44
- const targetBase = claudeHome || getClaudeHome();
45
- const sourceBase = join(SCAFFOLD_GLOBAL, '.claude');
46
+ async function copyTree(sourceBase, targetBase, { force }) {
47
+ let files;
48
+ try {
49
+ files = await listFiles(sourceBase);
50
+ } catch (err) {
51
+ if (err.code === 'ENOENT') return { written: [], skipped: [] };
52
+ throw err;
53
+ }
46
54
 
47
- const files = await listFiles(sourceBase);
48
55
  const written = [];
49
56
  const skipped = [];
50
57
 
@@ -52,7 +59,6 @@ export async function installGlobal(options = {}) {
52
59
  const src = join(sourceBase, relPath);
53
60
  const dest = join(targetBase, relPath);
54
61
 
55
- // Check if file exists and skip unless force
56
62
  if (!force) {
57
63
  try {
58
64
  await stat(dest);
@@ -66,7 +72,6 @@ export async function installGlobal(options = {}) {
66
72
  await mkdir(dirname(dest), { recursive: true });
67
73
  await cp(src, dest);
68
74
 
69
- // Make .sh files executable
70
75
  if (relPath.endsWith('.sh')) {
71
76
  const { chmod } = await import('node:fs/promises');
72
77
  await chmod(dest, 0o755);
@@ -78,6 +83,36 @@ export async function installGlobal(options = {}) {
78
83
  return { written, skipped };
79
84
  }
80
85
 
86
+ /**
87
+ * Install global Claude Code artifacts to ~/.claude/ AND ~/.claudenv/.
88
+ * Copies scaffold/global/.claude/ → ~/.claude/ and scaffold/global-claudenv/ → ~/.claudenv/.
89
+ *
90
+ * @param {object} [options]
91
+ * @param {boolean} [options.force] - Overwrite existing files
92
+ * @param {string} [options.claudeHome] - Override ~/.claude/ path (for testing)
93
+ * @param {string} [options.claudenvHome] - Override ~/.claudenv/ path (for testing)
94
+ * @returns {Promise<{written: string[], skipped: string[], claudenvWritten: string[], claudenvSkipped: string[]}>}
95
+ */
96
+ export async function installGlobal(options = {}) {
97
+ const { force = false, claudeHome, claudenvHome } = options;
98
+ const claudeTarget = claudeHome || getClaudeHome();
99
+ const claudenvTarget = claudenvHome || getClaudenvHome();
100
+
101
+ const claude = await copyTree(join(SCAFFOLD_GLOBAL, '.claude'), claudeTarget, { force });
102
+ const claudenv = await copyTree(SCAFFOLD_GLOBAL_CLAUDENV, claudenvTarget, { force });
103
+
104
+ // Ensure baseline subdirectories exist even when scaffold doesn't carry .gitkeep
105
+ // (decisions/ is empty by design until vibe-decisions fills it).
106
+ await mkdir(join(claudenvTarget, 'memories', 'decisions'), { recursive: true });
107
+
108
+ return {
109
+ written: claude.written,
110
+ skipped: claude.skipped,
111
+ claudenvWritten: claudenv.written,
112
+ claudenvSkipped: claudenv.skipped,
113
+ };
114
+ }
115
+
81
116
  /**
82
117
  * Remove global Claude Code artifacts from ~/.claude/.
83
118
  *
@@ -95,7 +130,13 @@ export async function uninstallGlobal(options = {}) {
95
130
  join(targetBase, 'commands', 'autonomy.md'),
96
131
  join(targetBase, 'commands', 'setup-mcp.md'),
97
132
  join(targetBase, 'commands', 'improve.md'),
133
+ join(targetBase, 'commands', 'deeper.md'),
134
+ join(targetBase, 'commands', 'why.md'),
135
+ join(targetBase, 'commands', 'decisions.md'),
136
+ join(targetBase, 'commands', 'canon.md'),
137
+ join(targetBase, 'commands', 'just-code.md'),
98
138
  join(targetBase, 'skills', 'claudenv'),
139
+ join(targetBase, 'skills', 'vibe-decisions'),
99
140
  ];
100
141
 
101
142
  for (const target of targets) {
package/src/loop.js CHANGED
@@ -3,6 +3,8 @@ import { readFile, writeFile, mkdir, appendFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { select } from '@inquirer/prompts';
5
5
  import { createWorktree, removeWorktree, mergeWorktree, getCurrentBranch } from './worktree.js';
6
+ import { buildSystemPromptWithMemory } from './memory-context.js';
7
+ import { regenIndex } from './hooks/regen-index.js';
6
8
 
7
9
  // =============================================
8
10
  // Work report helpers
@@ -51,6 +53,7 @@ export function checkClaudeCli() {
51
53
  * @param {string} [options.model] - Model to use
52
54
  * @param {number} [options.budget] - Budget cap in USD
53
55
  * @param {string} [options.appendSystemPrompt] - Append to system prompt
56
+ * @param {object} [options.env] - Extra env vars to merge into the child process env
54
57
  * @returns {Promise<{ result: string, sessionId: string|null, usage: object|null }>}
55
58
  */
56
59
  export function spawnClaude(prompt, options = {}) {
@@ -81,6 +84,7 @@ export function spawnClaude(prompt, options = {}) {
81
84
 
82
85
  const child = spawn('claude', args, {
83
86
  cwd: options.cwd || process.cwd(),
87
+ env: options.env ? { ...process.env, ...options.env } : process.env,
84
88
  // stdin=inherit avoids Node.js spawn hang bug, stdout=pipe to capture JSON, stderr=pipe for rate limit detection
85
89
  stdio: ['inherit', 'pipe', 'pipe'],
86
90
  });
@@ -520,6 +524,14 @@ export async function runLoop(options = {}) {
520
524
  };
521
525
 
522
526
  // --- Shared spawn options ---
527
+ // appendSystemPrompt now includes:
528
+ // - autonomy=law directive (from goal)
529
+ // - vibe-decisions loop-mode fragment (so the skill auto-logs without pause)
530
+ // - memory briefing (INDEX.md contents) if present
531
+ // The prompt is stable for the session; the prefix stays cache-friendly.
532
+ const appendedPrompt = await buildSystemPromptWithMemory(options.goal, {
533
+ autonomyBuilder: buildAutonomySystemPrompt,
534
+ });
523
535
  const spawnOpts = {
524
536
  cwd,
525
537
  trust: options.trust || false,
@@ -527,7 +539,12 @@ export async function runLoop(options = {}) {
527
539
  maxTurns: options.maxTurns || 30,
528
540
  model: options.model,
529
541
  budget: options.budget,
530
- appendSystemPrompt: options.goal ? buildAutonomySystemPrompt(options.goal) : undefined,
542
+ appendSystemPrompt: appendedPrompt,
543
+ // Secondary marker for Python helpers (e.g. claudenv-memory 0.2.x) that
544
+ // want to detect they're being driven by `claudenv loop`. The PRIMARY
545
+ // mode signal stays the directive fragment in appendSystemPrompt above —
546
+ // env alone wouldn't be readable by the markdown skill.
547
+ env: { CLAUDENV_LOOP: '1' },
531
548
  };
532
549
 
533
550
  let sessionId = options.initialSessionId || null;
@@ -585,6 +602,10 @@ export async function runLoop(options = {}) {
585
602
  tokens: planResult.usage ? { in: planResult.usage.input_tokens, out: planResult.usage.output_tokens } : null,
586
603
  });
587
604
 
605
+ // Fallback for SessionEnd hook — regen INDEX.md so the next iteration's
606
+ // briefing reflects anything written during planning.
607
+ try { await regenIndex({ cwd }); } catch { /* best-effort */ }
608
+
588
609
  if (shuttingDown) {
589
610
  log.stopReason = 'interrupted';
590
611
  log.completedAt = new Date().toISOString();
@@ -773,6 +794,10 @@ export async function runLoop(options = {}) {
773
794
  tokens: iterResult.usage ? { in: iterResult.usage.input_tokens, out: iterResult.usage.output_tokens } : null,
774
795
  });
775
796
 
797
+ // Fallback for SessionEnd hook — regen INDEX.md from accumulated decisions
798
+ // so the next iteration's briefing is current.
799
+ try { await regenIndex({ cwd }); } catch { /* best-effort */ }
800
+
776
801
  // --- Convergence check ---
777
802
  if (detectConvergence(iterResult.result)) {
778
803
  console.log('\n All improvements completed.');
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Builds the `--append-system-prompt` payload for `claudenv loop`.
3
+ *
4
+ * Three composable fragments, joined with blank lines:
5
+ *
6
+ * 1. Autonomy=law directive from the goal (existing 1.2.x behavior)
7
+ * 2. Vibe-decisions loop-mode marker so the skill skips pause-and-ask
8
+ * 3. Memory briefing — current INDEX.md contents, capped for cache stability
9
+ *
10
+ * Cache discipline: the function is pure on its inputs (goal + file state at
11
+ * call time). Loop.js calls it once per iteration but with stable inputs
12
+ * within a session, so the appended prompt remains stable.
13
+ */
14
+
15
+ import { readFile } from 'node:fs/promises';
16
+ import { indexMdPath } from './memory-paths.js';
17
+
18
+ const VIBE_LOOP_FRAGMENT = `## Vibe-decisions mode (loop)
19
+
20
+ You are running inside \`claudenv loop\` in autonomous mode. When the
21
+ vibe-decisions skill triggers: pick the approach, write the decision log
22
+ immediately (Write tool to /memories/decisions/<date>-<slug>.md with the
23
+ __VIBE_DECISION__ marker), continue with code. Do NOT pause for user
24
+ confirmation. Do NOT ask "делать так?". The goal is law.`;
25
+
26
+ const MAX_BRIEFING_CHARS = 4000;
27
+
28
+ /**
29
+ * Build the system prompt suffix.
30
+ *
31
+ * @param {string|null} goal - The loop's --goal value (may be null)
32
+ * @param {object} [opts]
33
+ * @param {(goal: string) => string} [opts.autonomyBuilder] - Caller's autonomy prompt builder
34
+ * @param {boolean} [opts.includeMemoryBriefing] - Default true; false skips INDEX.md read (for tests)
35
+ * @returns {Promise<string|undefined>} The appended prompt, or undefined if nothing to add
36
+ */
37
+ export async function buildSystemPromptWithMemory(goal, opts = {}) {
38
+ const parts = [];
39
+
40
+ if (goal && opts.autonomyBuilder) {
41
+ parts.push(opts.autonomyBuilder(goal));
42
+ }
43
+
44
+ // Vibe-decisions loop mode is always on inside `claudenv loop`.
45
+ parts.push(VIBE_LOOP_FRAGMENT);
46
+
47
+ // Memory briefing — INDEX.md if present.
48
+ if (opts.includeMemoryBriefing !== false) {
49
+ try {
50
+ const briefing = await readFile(indexMdPath(), 'utf-8');
51
+ const capped =
52
+ briefing.length > MAX_BRIEFING_CHARS
53
+ ? briefing.slice(0, MAX_BRIEFING_CHARS) + '\n…(truncated)'
54
+ : briefing;
55
+ parts.push('## Memory briefing\n\n' + capped);
56
+ } catch {
57
+ /* INDEX.md absent — first session, fine */
58
+ }
59
+ }
60
+
61
+ if (parts.length === 0) return undefined;
62
+ return parts.join('\n\n');
63
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Shared resolvers for the claudenv memory layout + a minimal YAML frontmatter
3
+ * parser used by hooks, CLI commands, and the loop integration.
4
+ *
5
+ * All path getters read process.env.CLAUDENV_HOME so tests can isolate without
6
+ * touching the real ~/.claudenv/.
7
+ */
8
+
9
+ import { join } from 'node:path';
10
+ import { homedir } from 'node:os';
11
+
12
+ export function claudenvHome() {
13
+ return process.env.CLAUDENV_HOME || join(homedir(), '.claudenv');
14
+ }
15
+
16
+ export function memoriesDir() {
17
+ return join(claudenvHome(), 'memories');
18
+ }
19
+
20
+ export function globalDecisionsDir() {
21
+ return join(memoriesDir(), 'decisions');
22
+ }
23
+
24
+ export function canonIndexPath() {
25
+ return join(memoriesDir(), 'canon', 'index.yaml');
26
+ }
27
+
28
+ export function userPrefsPath() {
29
+ return join(memoriesDir(), 'user', 'preferences.md');
30
+ }
31
+
32
+ export function indexMdPath() {
33
+ return join(memoriesDir(), 'INDEX.md');
34
+ }
35
+
36
+ export function dirtyFlagPath() {
37
+ return join(claudenvHome(), '.index-dirty');
38
+ }
39
+
40
+ export function projectDecisionsDir(cwd) {
41
+ return join(cwd, '.claude', 'memories', 'decisions');
42
+ }
43
+
44
+ /**
45
+ * Minimal YAML frontmatter parser. Returns null when no frontmatter is present.
46
+ * Handles `key: value` and inline arrays `[a, b, c]`. Heavier structures fall
47
+ * through as raw strings — frontmatter here is intentionally flat.
48
+ */
49
+ export function parseFrontmatter(text) {
50
+ const match = /^---\n([\s\S]*?)\n---/.exec(text);
51
+ if (!match) return null;
52
+ const result = {};
53
+ for (const line of match[1].split('\n')) {
54
+ const m = /^([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*)$/.exec(line);
55
+ if (!m) continue;
56
+ let value = m[2].trim();
57
+ if (value.startsWith('[') && value.endsWith(']')) {
58
+ value = value
59
+ .slice(1, -1)
60
+ .split(',')
61
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
62
+ .filter(Boolean);
63
+ } else {
64
+ value = value.replace(/^['"]|['"]$/g, '');
65
+ }
66
+ result[m[1]] = value;
67
+ }
68
+ return result;
69
+ }
70
+
71
+ /**
72
+ * Render a flat object back into a YAML frontmatter block.
73
+ * Preserves array fields as inline `[a, b]`.
74
+ */
75
+ export function renderFrontmatter(obj) {
76
+ const lines = ['---'];
77
+ for (const [key, value] of Object.entries(obj)) {
78
+ if (Array.isArray(value)) {
79
+ lines.push(`${key}: [${value.join(', ')}]`);
80
+ } else {
81
+ lines.push(`${key}: ${value}`);
82
+ }
83
+ }
84
+ lines.push('---');
85
+ return lines.join('\n');
86
+ }
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
+ }