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/README.md +93 -3
- package/bin/cli.js +192 -0
- package/package.json +1 -1
- package/scaffold/.claude/memories/README.md +44 -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/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 +175 -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 +55 -14
- package/src/loop.js +26 -1
- package/src/memory-context.js +63 -0
- package/src/memory-paths.js +86 -0
- package/src/memory.js +111 -0
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
|
-
*
|
|
35
|
-
*
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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:
|
|
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
|
+
}
|