claudenv 1.3.0 → 1.3.2

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.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * capabilities.js — `claudenv capabilities` (alias `caps`).
3
+ *
4
+ * Builds a structured map of everything the current claudenv install offers so
5
+ * that Claude can "connect to claudenv, understand it, and extend itself":
6
+ * installed skills, CLI surface, memory state, the active workspace + its
7
+ * connectors, the kimi-webbridge browser daemon, project MCP servers, and the
8
+ * skills registry. The harness skill runs this first as its self-introspection
9
+ * step.
10
+ *
11
+ * Everything degrades gracefully — a missing piece is reported, never thrown.
12
+ */
13
+
14
+ import { readFile, readdir, stat } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ import {
17
+ claudenvHome,
18
+ memoriesDir,
19
+ globalDecisionsDir,
20
+ indexMdPath,
21
+ activeWorkspaceId,
22
+ workspaceConnectorsDir,
23
+ skillsRegistryCachePath,
24
+ } from './memory-paths.js';
25
+ import { listInstalledSkills } from './skills-registry.js';
26
+ import { BUNDLED_CATALOG } from './bundled-catalog.js';
27
+ import { kimiStatus } from './kimi.js';
28
+
29
+ const CLI_SURFACE = [
30
+ { cmd: 'claudenv loop', what: 'autonomous improvement loop (goal = law)' },
31
+ { cmd: 'claudenv autonomy', what: 'autonomy profiles (safe/moderate/full/ci)' },
32
+ { cmd: 'claudenv memory', what: 'global memory: init / index / show / edit' },
33
+ { cmd: 'claudenv decisions', what: 'vibe-decisions: list / show / search / archive' },
34
+ { cmd: 'claudenv canon', what: 'personal canon of references' },
35
+ { cmd: 'claudenv workspace', what: 'isolated per-company/context memory spaces' },
36
+ { cmd: 'claudenv source', what: 'data-source connectors of the active workspace' },
37
+ { cmd: 'claudenv skills', what: 'discover & install skills (search / list / add / info / refresh)' },
38
+ { cmd: 'claudenv capabilities', what: 'this self-introspection map' },
39
+ { cmd: 'claudenv doctor', what: 'health-check the setup' },
40
+ ];
41
+
42
+ async function pathExists(p) {
43
+ try {
44
+ await stat(p);
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ async function countDecisions() {
52
+ try {
53
+ const names = await readdir(globalDecisionsDir());
54
+ return names.filter((n) => n.endsWith('.md')).length;
55
+ } catch {
56
+ return 0;
57
+ }
58
+ }
59
+
60
+ async function readActiveConnectors() {
61
+ const id = activeWorkspaceId();
62
+ if (!id) return { active: null, connectors: [] };
63
+ let files = [];
64
+ try {
65
+ files = (await readdir(workspaceConnectorsDir(id))).filter((f) => f.endsWith('.md'));
66
+ } catch {
67
+ return { active: id, connectors: [] };
68
+ }
69
+ return { active: id, connectors: files.map((f) => f.replace(/\.md$/, '')) };
70
+ }
71
+
72
+ async function readProjectMcp(cwd) {
73
+ try {
74
+ const parsed = JSON.parse(await readFile(join(cwd, '.mcp.json'), 'utf-8'));
75
+ return Object.keys(parsed.mcpServers || {});
76
+ } catch {
77
+ return [];
78
+ }
79
+ }
80
+
81
+ async function countCache() {
82
+ try {
83
+ const arr = JSON.parse(await readFile(skillsRegistryCachePath(), 'utf-8'));
84
+ return Array.isArray(arr) ? arr.length : 0;
85
+ } catch {
86
+ return 0;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Build the capability map.
92
+ * @param {object} [opts]
93
+ * @param {string} [opts.cwd] - project dir (defaults to process.cwd())
94
+ * @param {string} [opts.claudeHome] - override ~/.claude (for tests)
95
+ * @param {string} [opts.version] - claudenv version string
96
+ */
97
+ export async function buildCapabilityMap(opts = {}) {
98
+ const cwd = opts.cwd || process.cwd();
99
+ const installed = await listInstalledSkills(opts.claudeHome);
100
+ const ws = await readActiveConnectors();
101
+
102
+ return {
103
+ version: opts.version || null,
104
+ node: process.version,
105
+ cli: CLI_SURFACE,
106
+ skills: {
107
+ installed,
108
+ count: installed.length,
109
+ },
110
+ memory: {
111
+ home: claudenvHome(),
112
+ indexExists: await pathExists(indexMdPath()),
113
+ memoriesExists: await pathExists(memoriesDir()),
114
+ decisions: await countDecisions(),
115
+ },
116
+ workspace: ws,
117
+ kimi: kimiStatus(),
118
+ mcp: { project: await readProjectMcp(cwd) },
119
+ registry: { bundled: BUNDLED_CATALOG.length, cached: await countCache() },
120
+ };
121
+ }
122
+
123
+ /** Render the capability map as a compact human-readable report. */
124
+ export function formatCapabilities(map) {
125
+ const L = [];
126
+ L.push(`claudenv capabilities${map.version ? ` v${map.version}` : ''} (Node ${map.node})`);
127
+ L.push('');
128
+
129
+ L.push(`Skills installed (~/.claude/skills): ${map.skills.count}`);
130
+ for (const s of map.skills.installed) {
131
+ L.push(` - ${s.slug}${s.hasSkillMd ? '' : ' (no SKILL.md)'}`);
132
+ }
133
+ if (map.skills.count === 0) L.push(' (none — try `claudenv skills search <task>`)');
134
+ L.push('');
135
+
136
+ const k = map.kimi;
137
+ const kimiState = !k.installed
138
+ ? 'not installed (`claudenv skills add kimi-webbridge`)'
139
+ : k.running && k.extensionConnected
140
+ ? 'healthy (daemon + extension connected)'
141
+ : k.running
142
+ ? 'daemon up, browser extension NOT connected'
143
+ : 'installed, daemon stopped (`~/.kimi-webbridge/bin/kimi-webbridge start`)';
144
+ L.push(`Browser automation (kimi-webbridge): ${kimiState}`);
145
+ L.push('');
146
+
147
+ L.push('Memory & workspaces:');
148
+ L.push(` global memory: ${map.memory.memoriesExists ? 'present' : 'not initialised (`claudenv memory init`)'}, ${map.memory.decisions} decisions, INDEX.md ${map.memory.indexExists ? 'present' : 'absent'}`);
149
+ if (map.workspace.active) {
150
+ L.push(` active workspace: ${map.workspace.active} — connectors: ${map.workspace.connectors.length ? map.workspace.connectors.join(', ') : 'none'}`);
151
+ } else {
152
+ L.push(' active workspace: none (`claudenv workspace use <id>`)');
153
+ }
154
+ L.push('');
155
+
156
+ L.push(`Project MCP servers (.mcp.json): ${map.mcp.project.length ? map.mcp.project.join(', ') : 'none'}`);
157
+ L.push(`Skills registry: ${map.registry.bundled} curated + ${map.registry.cached} cached live`);
158
+ L.push('');
159
+
160
+ L.push('CLI surface:');
161
+ for (const c of map.cli) L.push(` ${c.cmd.padEnd(24)} ${c.what}`);
162
+
163
+ return L.join('\n');
164
+ }
package/src/doctor.js CHANGED
@@ -13,7 +13,11 @@ 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';
20
+ import { kimiStatus } from './kimi.js';
17
21
 
18
22
  const OK = '[OK] ';
19
23
  const WARN = '[WARN]';
@@ -103,6 +107,34 @@ async function vibeDecisionsCheck() {
103
107
  };
104
108
  }
105
109
 
110
+ async function harnessSkillCheck() {
111
+ const skill = join(homedir(), '.claude', 'skills', 'harness', 'SKILL.md');
112
+ if (await pathExists(skill)) {
113
+ return { status: OK, msg: 'harness skill installed globally (self-extension)' };
114
+ }
115
+ return {
116
+ status: WARN,
117
+ msg: 'harness skill not installed — run `claudenv install --force`',
118
+ };
119
+ }
120
+
121
+ async function kimiBridgeCheck() {
122
+ const k = kimiStatus();
123
+ if (!k.installed) {
124
+ return {
125
+ status: WARN,
126
+ msg: 'kimi-webbridge not installed — optional browser automation (`claudenv skills add kimi-webbridge`)',
127
+ };
128
+ }
129
+ if (k.running && k.extensionConnected) {
130
+ return { status: OK, msg: 'kimi-webbridge healthy (daemon + extension connected)' };
131
+ }
132
+ if (k.running) {
133
+ return { status: WARN, msg: 'kimi-webbridge daemon up, browser extension not connected' };
134
+ }
135
+ return { status: WARN, msg: 'kimi-webbridge installed, daemon stopped (`kimi-webbridge start`)' };
136
+ }
137
+
106
138
  async function projectHooksCheck() {
107
139
  const settingsPath = join(process.cwd(), '.claude', 'settings.json');
108
140
  if (!(await pathExists(settingsPath))) {
@@ -151,6 +183,28 @@ async function countDecisionsCheck() {
151
183
  return { status: OK, msg: `${count} global decisions logged` };
152
184
  }
153
185
 
186
+ async function workspaceLeakCheck() {
187
+ const id = activeWorkspaceId();
188
+ if (!id) return { status: OK, msg: 'no active workspace — secret-leak scan skipped' };
189
+ let files;
190
+ try {
191
+ files = (await readdir(workspaceConnectorsDir(id))).filter((f) => f.endsWith('.md'));
192
+ } catch {
193
+ return { status: OK, msg: `workspace "${id}": no connectors to scan` };
194
+ }
195
+ const leaks = [];
196
+ for (const f of files) {
197
+ try {
198
+ const found = scanForSecretLeaks(await readFile(join(workspaceConnectorsDir(id), f), 'utf-8'));
199
+ if (found.length) leaks.push(`${f}:${found[0].line}`);
200
+ } catch { /* skip */ }
201
+ }
202
+ if (leaks.length) {
203
+ return { status: FAIL, msg: `secret values in workspace memory: ${leaks.join(', ')} — move to .env.local` };
204
+ }
205
+ return { status: OK, msg: `workspace "${id}": connector memory clean (no secret values)` };
206
+ }
207
+
154
208
  export async function runDoctor() {
155
209
  const checks = [
156
210
  await nodeVersionCheck(),
@@ -159,9 +213,12 @@ export async function runDoctor() {
159
213
  await memoriesLayoutCheck(),
160
214
  await indexSizeCheck(),
161
215
  await vibeDecisionsCheck(),
216
+ await harnessSkillCheck(),
217
+ await kimiBridgeCheck(),
162
218
  await projectHooksCheck(),
163
219
  await pythonModuleCheck(),
164
220
  await countDecisionsCheck(),
221
+ await workspaceLeakCheck(),
165
222
  ];
166
223
 
167
224
  let hasFail = false;
package/src/installer.js CHANGED
@@ -135,8 +135,12 @@ 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'),
139
+ join(targetBase, 'commands', 'harness.md'),
138
140
  join(targetBase, 'skills', 'claudenv'),
139
141
  join(targetBase, 'skills', 'vibe-decisions'),
142
+ join(targetBase, 'skills', 'source-connector'),
143
+ join(targetBase, 'skills', 'harness'),
140
144
  ];
141
145
 
142
146
  for (const target of targets) {
package/src/kimi.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * kimi-webbridge status helper — shared by `claudenv capabilities` and
3
+ * `claudenv doctor` so the daemon shell-out lives in exactly one place.
4
+ *
5
+ * kimi-webbridge drives the user's real browser via a local daemon. The binary
6
+ * always lives at ~/.kimi-webbridge/bin/kimi-webbridge (per its operations.md).
7
+ */
8
+
9
+ import { existsSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { homedir } from 'node:os';
12
+ import { execSync } from 'node:child_process';
13
+
14
+ export function kimiBinPath() {
15
+ return join(homedir(), '.kimi-webbridge', 'bin', 'kimi-webbridge');
16
+ }
17
+
18
+ /**
19
+ * Resolve the daemon state. Never throws.
20
+ * @returns {{installed: boolean, running: boolean, extensionConnected: boolean, version: string|null}}
21
+ */
22
+ export function kimiStatus() {
23
+ const bin = kimiBinPath();
24
+ if (!existsSync(bin)) {
25
+ return { installed: false, running: false, extensionConnected: false, version: null };
26
+ }
27
+ try {
28
+ const out = execSync(`"${bin}" status`, {
29
+ encoding: 'utf-8',
30
+ stdio: ['pipe', 'pipe', 'pipe'],
31
+ timeout: 5000,
32
+ }).trim();
33
+ const json = JSON.parse(out);
34
+ return {
35
+ installed: true,
36
+ running: !!json.running,
37
+ extensionConnected: !!json.extension_connected,
38
+ version: json.version || null,
39
+ };
40
+ } catch {
41
+ return { installed: true, running: false, extensionConnected: false, version: null };
42
+ }
43
+ }
package/src/loop.js CHANGED
@@ -246,10 +246,32 @@ Generated by claudenv loop. Goal: General improvement.
246
246
  7. Commit the improvement plan: \`git add .claude/improvement-plan.md && git commit -m "Add improvement plan"\``;
247
247
  }
248
248
 
249
+ /**
250
+ * Orchestration directive injected into the execution prompt. Lives in the
251
+ * USER prompt (not the system prompt) on purpose: the Workflow tool's opt-in
252
+ * keys on the user message, so the literal word "workflow" here is what makes
253
+ * the tool actually reachable — a system-prompt fragment alone does not
254
+ * trigger it (measured). Guarded by the 3+-independent-sub-tasks condition so
255
+ * trivial or sequential items stay single-threaded (fan-out costs ~15×).
256
+ */
257
+ const ORCHESTRATION_DIRECTIVE = `## Parallel decomposition (dynamic workflow)
258
+
259
+ If — and ONLY if — the item you picked naturally decomposes into 3 or more
260
+ INDEPENDENT sub-tasks (the same operation across many files, a multi-dimension
261
+ review/audit, several independent builds-then-judge), you MUST run a dynamic
262
+ **workflow** via the Workflow tool to fan them out instead of doing them one by
263
+ one. Keep the workflow small (cap concurrent agents to a handful) — it runs
264
+ under this iteration's budget and multi-agent fan-out costs ~15× the tokens.
265
+ For a single file, strictly sequential work, or trivial edits, do NOT use a
266
+ workflow — stay single-threaded. See the dynamic-workflows skill for patterns.
267
+ If the Workflow tool is unavailable in this run, fall back to a few parallel
268
+ Agent calls for the same fan-out.`;
269
+
249
270
  /**
250
271
  * Build the execution prompt for iterations 1-N.
272
+ * Exported for testing (and to let the faithful smoke test reuse the real prompt).
251
273
  */
252
- function buildExecutionPrompt(iteration, maxIterations, goal) {
274
+ export function buildExecutionPrompt(iteration, maxIterations, goal) {
253
275
  const maxLine = maxIterations ? ` (iteration ${iteration} of ${maxIterations})` : ` (iteration ${iteration})`;
254
276
 
255
277
  if (goal) {
@@ -273,6 +295,8 @@ GOAL: ${goal}
273
295
  5. Commit all changes with a descriptive message
274
296
  6. Report what you did in a brief summary
275
297
 
298
+ ${ORCHESTRATION_DIRECTIVE}
299
+
276
300
  ## Critical rules
277
301
  - You MUST implement real changes. Writing docs or analysis alone does NOT count.
278
302
  - Create files, write code, build features, install dependencies — whatever it takes.
@@ -301,6 +325,8 @@ GOAL: ${goal}
301
325
  5. Commit all changes with a descriptive message
302
326
  6. Report what you did in a brief summary
303
327
 
328
+ ${ORCHESTRATION_DIRECTIVE}
329
+
304
330
  ## Important rules
305
331
  - Do NOT delete files unless the deletion IS the improvement itself
306
332
  - Do NOT make changes beyond the single item you picked
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Builds the `--append-system-prompt` payload for `claudenv loop`.
3
3
  *
4
- * Three composable fragments, joined with blank lines:
4
+ * Four composable fragments, joined with blank lines:
5
5
  *
6
6
  * 1. Autonomy=law directive from the goal (existing 1.2.x behavior)
7
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
8
+ * 3. Dynamic-workflows loop-mode marker so the loop may fan out decomposable
9
+ * plan items via the Workflow tool (with a hard fan-out cap)
10
+ * 4. Memory briefing — current INDEX.md contents, capped for cache stability
9
11
  *
10
12
  * Cache discipline: the function is pure on its inputs (goal + file state at
11
13
  * call time). Loop.js calls it once per iteration but with stable inputs
@@ -23,6 +25,31 @@ immediately (Write tool to /memories/decisions/<date>-<slug>.md with the
23
25
  __VIBE_DECISION__ marker), continue with code. Do NOT pause for user
24
26
  confirmation. Do NOT ask "делать так?". The goal is law.`;
25
27
 
28
+ const WORKFLOW_LOOP_FRAGMENT = `## Dynamic-workflows mode (loop)
29
+
30
+ When a plan item genuinely splits into many INDEPENDENT sub-tasks (the same
31
+ operation over many files, several review dimensions, N independent attempts),
32
+ you MAY orchestrate them with the Workflow tool instead of working serially —
33
+ see the dynamic-workflows skill for patterns. Otherwise stay single-threaded:
34
+ one item, one focused change. Hard limits — fan-out costs ~15× the tokens and
35
+ this iteration runs under a per-iteration budget cap, so orchestrate only when
36
+ the width is real and keep the number of concurrent agents small (a handful,
37
+ not dozens). If the Workflow tool's background completion does not resolve
38
+ under this headless run, fall back to a few parallel Agent calls for the same
39
+ fan-out. Never pause to ask whether to orchestrate — the goal is law.`;
40
+
41
+ const HARNESS_LOOP_FRAGMENT = `## Harness mode (loop)
42
+
43
+ You can extend your own harness. If the current plan item would be done far
44
+ better with a capability you lack — a skill, connector, MCP, or browser — run
45
+ \`claudenv capabilities\` to see what you have, \`claudenv skills search "<need>"\`
46
+ to find it, and \`claudenv skills add <slug>\` to equip it. The goal is law: do
47
+ not pause to ask. HARD limit — only auto-install CURATED (★) skills and
48
+ known-safe bootstraps; a fetched SKILL.md is auto-loaded model-facing text, so
49
+ NEVER auto-install a live (non-curated) skill in this headless run — note the
50
+ gap and proceed with what you have. Secrets only in .env.local; connector
51
+ knowledge into the active workspace. See the harness skill for the full flow.`;
52
+
26
53
  const MAX_BRIEFING_CHARS = 4000;
27
54
 
28
55
  /**
@@ -44,6 +71,14 @@ export async function buildSystemPromptWithMemory(goal, opts = {}) {
44
71
  // Vibe-decisions loop mode is always on inside `claudenv loop`.
45
72
  parts.push(VIBE_LOOP_FRAGMENT);
46
73
 
74
+ // Dynamic-workflows loop mode — lets the loop fan out decomposable plan
75
+ // items via the Workflow tool, with a hard cap on fan-out width.
76
+ parts.push(WORKFLOW_LOOP_FRAGMENT);
77
+
78
+ // Harness loop mode — lets the loop self-equip missing capabilities
79
+ // (curated skills only) instead of doing tooling-shaped work by hand.
80
+ parts.push(HARNESS_LOOP_FRAGMENT);
81
+
47
82
  // Memory briefing — INDEX.md if present.
48
83
  if (opts.includeMemoryBriefing !== false) {
49
84
  try {
@@ -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');
@@ -37,10 +38,70 @@ export function dirtyFlagPath() {
37
38
  return join(claudenvHome(), '.index-dirty');
38
39
  }
39
40
 
41
+ // --- Skills layer (~/.claude/skills/ — where Claude Code loads skills from) ---
42
+
43
+ /**
44
+ * Path to ~/.claude/ — where Claude Code reads global commands and skills.
45
+ * Distinct from claudenvHome() (~/.claudenv/), which holds memory/canon/workspaces.
46
+ */
47
+ export function claudeDir() {
48
+ return process.env.CLAUDE_HOME || join(homedir(), '.claude');
49
+ }
50
+
51
+ export function claudeSkillsDir() {
52
+ return join(claudeDir(), 'skills');
53
+ }
54
+
55
+ /** Cache of the parsed awesome-claude-skills registry. */
56
+ export function skillsRegistryCachePath() {
57
+ return join(claudenvHome(), 'skills-registry.json');
58
+ }
59
+
40
60
  export function projectDecisionsDir(cwd) {
41
61
  return join(cwd, '.claude', 'memories', 'decisions');
42
62
  }
43
63
 
64
+ // --- Workspace layer (isolated per-company/context memory) ---
65
+
66
+ export function workspacesDir() {
67
+ return join(claudenvHome(), 'workspaces');
68
+ }
69
+
70
+ export function activeWorkspaceFile() {
71
+ return join(claudenvHome(), 'active-workspace');
72
+ }
73
+
74
+ export function workspaceDir(id) {
75
+ return join(workspacesDir(), id);
76
+ }
77
+
78
+ export function workspaceManifestPath(id) {
79
+ return join(workspaceDir(id), 'workspace.yaml');
80
+ }
81
+
82
+ export function workspaceConnectorsDir(id) {
83
+ return join(workspaceDir(id), 'memories', 'connectors');
84
+ }
85
+
86
+ export function workspaceContextDir(id) {
87
+ return join(workspaceDir(id), 'memories', 'context');
88
+ }
89
+
90
+ /**
91
+ * Resolve the active workspace id. Priority: env CLAUDENV_WORKSPACE, then the
92
+ * pointer file ~/.claudenv/active-workspace. Returns null if none set.
93
+ * Intentionally does NOT scan all workspaces - isolation barrier.
94
+ */
95
+ export function activeWorkspaceId() {
96
+ if (process.env.CLAUDENV_WORKSPACE) return process.env.CLAUDENV_WORKSPACE.trim();
97
+ try {
98
+ const id = readFileSync(activeWorkspaceFile(), 'utf-8').trim();
99
+ return id || null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
44
105
  /**
45
106
  * Minimal YAML frontmatter parser. Returns null when no frontmatter is present.
46
107
  * Handles `key: value` and inline arrays `[a, b, c]`. Heavier structures fall