coding-friend-cli 1.27.1 → 1.28.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.
Files changed (31) hide show
  1. package/README.md +9 -3
  2. package/dist/{chunk-PRIH34UB.js → chunk-DHH6SRXV.js} +5 -1
  3. package/dist/{chunk-75IEE2OE.js → chunk-E7OY4UWW.js} +2 -2
  4. package/dist/chunk-J4N2ODQ5.js +191 -0
  5. package/dist/{chunk-YCWRBOB3.js → chunk-RGQGKOBH.js} +1 -1
  6. package/dist/{chunk-GDZTU2Z4.js → chunk-XQ5KAXKL.js} +1 -1
  7. package/dist/{chunk-BX3DRKTU.js → chunk-XROT7L2F.js} +6 -5
  8. package/dist/{config-N55LTJ76.js → config-U2WEZXF6.js} +4 -4
  9. package/dist/{dev-BSXR55PZ.js → dev-4OAJCQRQ.js} +29 -5
  10. package/dist/guide-F2FLCDYO.js +113 -0
  11. package/dist/{host-JVLWYY5A.js → host-2KBOPER6.js} +2 -2
  12. package/dist/index.js +35 -26
  13. package/dist/{init-KX6Y3ELD.js → init-R5L2GAFV.js} +6 -6
  14. package/dist/{install-G4XYNVL3.js → install-XZC6UO54.js} +3 -3
  15. package/dist/{mcp-DABSO5PE.js → mcp-CNZLJ57B.js} +4 -4
  16. package/dist/{memory-7Q5P7LIU.js → memory-KU2MOCPQ.js} +4 -4
  17. package/dist/{session-DMQZXZ7F.js → session-ZGNQKV4M.js} +26 -7
  18. package/dist/{status-K7TBUAXT.js → status-JXUXVHRH.js} +4 -4
  19. package/dist/{statusline-CDGADB3C.js → statusline-RMTO4MQA.js} +2 -2
  20. package/dist/{update-6M3YFHS6.js → update-HS6A2C5V.js} +3 -3
  21. package/lib/cf-memory/README.md +4 -4
  22. package/lib/cf-memory/src/__tests__/claude-md.test.ts +105 -0
  23. package/lib/cf-memory/src/__tests__/tier.test.ts +59 -0
  24. package/lib/cf-memory/src/daemon/process.ts +1 -1
  25. package/lib/cf-memory/src/lib/claude-md.ts +12 -3
  26. package/lib/cf-memory/src/lib/tier.ts +19 -7
  27. package/lib/cf-memory/src/tools/delete.ts +3 -3
  28. package/lib/cf-memory/src/tools/store.ts +14 -2
  29. package/lib/cf-memory/src/tools/update.ts +21 -3
  30. package/package.json +7 -4
  31. package/dist/chunk-2HQVNQB7.js +0 -71
package/README.md CHANGED
@@ -147,19 +147,25 @@ npm ls -g coding-friend-cli
147
147
 
148
148
  ### Running tests
149
149
 
150
- Tests are written with [Vitest](https://vitest.dev/) and live in `src/lib/__tests__/`.
150
+ Tests are written with [Vitest](https://vitest.dev/).
151
151
 
152
152
  ```bash
153
153
  cd cli
154
154
 
155
- # Run all tests once
155
+ # Run all unit tests once
156
156
  npm test
157
157
 
158
158
  # Watch mode (re-runs on file changes)
159
159
  npm run test:watch
160
+
161
+ # Run E2E tests (real CLI commands against temp directories)
162
+ npm run test:e2e
163
+
164
+ # Run E2E tests in Docker (isolated, no host pollution)
165
+ cd e2e && docker compose up --build --abort-on-container-exit
160
166
  ```
161
167
 
162
- Current coverage: `lib/json.ts`, `lib/paths.ts`, `lib/exec.ts`.
168
+ Unit tests live in `src/**/__tests__/` and use mocks. E2E tests live in `e2e/` and run actual CLI commands — see [`e2e/README.md`](e2e/README.md) for details.
163
169
 
164
170
  ## Publish CLI to npm
165
171
 
@@ -6,12 +6,16 @@ var STATUSLINE_COMPONENTS = [
6
6
  { id: "branch", label: "Git branch (\u2387 main)" },
7
7
  {
8
8
  id: "account",
9
- label: "Account info \u2014 name, email & org (from ~/.claude.json)"
9
+ label: "Account info \u2014 name, email (from ~/.claude.json)"
10
10
  },
11
11
  { id: "context", label: "Context window usage (ctx 42%)" },
12
12
  {
13
13
  id: "rate_limit",
14
14
  label: "Rate limit \u2014 current & weekly usage (requires curl & jq)"
15
+ },
16
+ {
17
+ id: "task_agent",
18
+ label: "Task progress & active agent (Tasks: 2/5 | Agent: cf-reviewer)"
15
19
  }
16
20
  ];
17
21
  var ALL_COMPONENT_IDS = STATUSLINE_COMPONENTS.map((c) => c.id);
@@ -7,14 +7,14 @@ import {
7
7
  getMemoryMcpStatus,
8
8
  memoryConfigMenu,
9
9
  writeMemoryMcpEntry
10
- } from "./chunk-BX3DRKTU.js";
10
+ } from "./chunk-XROT7L2F.js";
11
11
  import {
12
12
  getLibPath
13
13
  } from "./chunk-RZRT7NGT.js";
14
14
  import {
15
15
  loadConfig,
16
16
  resolveMemoryDir
17
- } from "./chunk-2HQVNQB7.js";
17
+ } from "./chunk-J4N2ODQ5.js";
18
18
  import {
19
19
  showConfigHint
20
20
  } from "./chunk-HPNRQYLM.js";
@@ -0,0 +1,191 @@
1
+ import {
2
+ DEFAULT_CONFIG
3
+ } from "./chunk-DHH6SRXV.js";
4
+ import {
5
+ globalConfigPath,
6
+ localConfigPath,
7
+ resolvePath
8
+ } from "./chunk-TWKNGPBO.js";
9
+ import {
10
+ log
11
+ } from "./chunk-NREZK463.js";
12
+ import {
13
+ readJson
14
+ } from "./chunk-5UVDWG5L.js";
15
+
16
+ // src/lib/config.ts
17
+ import { z } from "zod";
18
+ function deepMerge(base, override) {
19
+ const result = { ...base };
20
+ for (const key of Object.keys(override)) {
21
+ const baseVal = result[key];
22
+ const overVal = override[key];
23
+ if (baseVal && overVal && typeof baseVal === "object" && typeof overVal === "object" && !Array.isArray(baseVal) && !Array.isArray(overVal)) {
24
+ result[key] = deepMerge(
25
+ baseVal,
26
+ overVal
27
+ );
28
+ } else {
29
+ result[key] = overVal;
30
+ }
31
+ }
32
+ return result;
33
+ }
34
+ var LearnCategorySchema = z.object({
35
+ name: z.string(),
36
+ description: z.string()
37
+ });
38
+ var LearnConfigSchema = z.object({
39
+ language: z.string().optional(),
40
+ outputDir: z.string().optional(),
41
+ categories: z.array(LearnCategorySchema).optional(),
42
+ autoCommit: z.boolean().optional(),
43
+ readmeIndex: z.union([z.boolean(), z.literal("per-category")]).optional()
44
+ });
45
+ var StatuslineComponentSchema = z.enum([
46
+ "version",
47
+ "folder",
48
+ "model",
49
+ "branch",
50
+ "account",
51
+ "context",
52
+ "rate_limit",
53
+ "task_agent"
54
+ ]);
55
+ var StatuslineConfigSchema = z.object({
56
+ components: z.array(StatuslineComponentSchema).optional()
57
+ });
58
+ var MemoryConfigSchema = z.object({
59
+ tier: z.enum(["auto", "full", "lite", "markdown"]).optional(),
60
+ daemon: z.object({
61
+ idleTimeout: z.number().optional()
62
+ }).optional(),
63
+ embedding: z.object({
64
+ provider: z.enum(["transformers", "ollama"]).optional(),
65
+ model: z.string().optional(),
66
+ ollamaUrl: z.string().optional()
67
+ }).optional(),
68
+ autoCapture: z.boolean().optional(),
69
+ autoStart: z.boolean().optional()
70
+ });
71
+ var ConfigSchema = z.strictObject({
72
+ language: z.string().optional(),
73
+ docsDir: z.string().optional(),
74
+ learn: LearnConfigSchema.optional(),
75
+ statusline: StatuslineConfigSchema.optional(),
76
+ memory: MemoryConfigSchema.optional(),
77
+ autoApprove: z.boolean().optional()
78
+ });
79
+ var KNOWN_KEYS = [
80
+ "language",
81
+ "docsDir",
82
+ "learn",
83
+ "statusline",
84
+ "memory",
85
+ "autoApprove"
86
+ ];
87
+ function suggestKey(unknown) {
88
+ let best = null;
89
+ let bestDist = Infinity;
90
+ for (const known of KNOWN_KEYS) {
91
+ const dist = levenshtein(unknown.toLowerCase(), known.toLowerCase());
92
+ if (dist < bestDist && dist <= 3) {
93
+ bestDist = dist;
94
+ best = known;
95
+ }
96
+ }
97
+ return best;
98
+ }
99
+ function levenshtein(a, b) {
100
+ const m = a.length;
101
+ const n = b.length;
102
+ const dp = Array.from(
103
+ { length: m + 1 },
104
+ () => Array(n + 1).fill(0)
105
+ );
106
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
107
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
108
+ for (let i = 1; i <= m; i++) {
109
+ for (let j = 1; j <= n; j++) {
110
+ dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
111
+ }
112
+ }
113
+ return dp[m][n];
114
+ }
115
+ function validateConfig(merged) {
116
+ const result = ConfigSchema.safeParse(merged);
117
+ if (result.success) return result.data;
118
+ const sanitized = { ...merged };
119
+ for (const issue of result.error.issues) {
120
+ const pathStr = issue.path.join(".");
121
+ if (issue.code === "unrecognized_keys") {
122
+ const keys = issue.keys ?? [];
123
+ for (const key of keys) {
124
+ const suggestion = suggestKey(key);
125
+ const hint = suggestion ? ` Did you mean '${suggestion}'?` : "";
126
+ log.warn(`Unknown config key '${key}'.${hint}`);
127
+ delete sanitized[key];
128
+ }
129
+ } else {
130
+ log.warn(`Config: ${pathStr} \u2014 ${issue.message}. Using default.`);
131
+ stripPath(sanitized, issue.path);
132
+ }
133
+ }
134
+ return deepMerge(
135
+ DEFAULT_CONFIG,
136
+ sanitized
137
+ );
138
+ }
139
+ function stripPath(obj, path) {
140
+ if (path.length === 0) return;
141
+ let current = obj;
142
+ for (let i = 0; i < path.length - 1; i++) {
143
+ const key = String(path[i]);
144
+ if (current[key] && typeof current[key] === "object") {
145
+ current = current[key];
146
+ } else {
147
+ return;
148
+ }
149
+ }
150
+ delete current[String(path[path.length - 1])];
151
+ }
152
+ function loadConfig() {
153
+ const global = readJson(globalConfigPath());
154
+ const local = readJson(localConfigPath());
155
+ const base = deepMerge(
156
+ DEFAULT_CONFIG,
157
+ global ?? {}
158
+ );
159
+ const merged = deepMerge(base, local ?? {});
160
+ return validateConfig(merged);
161
+ }
162
+ function resolveDocsDir(explicitPath) {
163
+ if (explicitPath) {
164
+ return resolvePath(explicitPath);
165
+ }
166
+ const local = readJson(localConfigPath());
167
+ if (local?.learn?.outputDir) {
168
+ return resolvePath(local.learn.outputDir);
169
+ }
170
+ const global = readJson(globalConfigPath());
171
+ if (global?.learn?.outputDir) {
172
+ return resolvePath(global.learn.outputDir);
173
+ }
174
+ return resolvePath("docs/learn");
175
+ }
176
+ function resolveMemoryDir(explicitPath) {
177
+ if (explicitPath) {
178
+ return resolvePath(explicitPath);
179
+ }
180
+ const config = loadConfig();
181
+ if (config.docsDir) {
182
+ return resolvePath(`${config.docsDir}/memory`);
183
+ }
184
+ return resolvePath("docs/memory");
185
+ }
186
+
187
+ export {
188
+ loadConfig,
189
+ resolveDocsDir,
190
+ resolveMemoryDir
191
+ };
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ensureStatusline,
3
3
  getInstalledVersion
4
- } from "./chunk-GDZTU2Z4.js";
4
+ } from "./chunk-XQ5KAXKL.js";
5
5
  import {
6
6
  ensureShellCompletion
7
7
  } from "./chunk-DMJCO6LJ.js";
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ALL_COMPONENT_IDS,
3
3
  STATUSLINE_COMPONENTS
4
- } from "./chunk-PRIH34UB.js";
4
+ } from "./chunk-DHH6SRXV.js";
5
5
  import {
6
6
  claudeSettingsPath,
7
7
  globalConfigPath,
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-RZRT7NGT.js";
4
4
  import {
5
5
  resolveMemoryDir
6
- } from "./chunk-2HQVNQB7.js";
6
+ } from "./chunk-J4N2ODQ5.js";
7
7
  import {
8
8
  BACK,
9
9
  askScope,
@@ -222,16 +222,17 @@ async function editMemoryEmbedding(globalCfg, localCfg) {
222
222
  async function editMemoryDaemonTimeout(globalCfg, localCfg) {
223
223
  const currentDaemon = getMergedMemoryValue("daemon", globalCfg, localCfg);
224
224
  const currentMs = currentDaemon?.idleTimeout;
225
- const currentMin = currentMs ? currentMs / 6e4 : void 0;
225
+ const currentMin = currentMs !== void 0 ? currentMs / 6e4 : void 0;
226
226
  if (currentMin !== void 0) {
227
227
  log.dim(`Current: ${currentMin} minutes`);
228
228
  }
229
229
  const value = await input({
230
- message: "Daemon idle timeout (minutes):",
231
- default: String(currentMin ?? 30),
230
+ message: "Daemon idle timeout (minutes, 0 = no timeout):",
231
+ default: String(currentMin ?? 0),
232
232
  validate: (val) => {
233
233
  const n = Number(val);
234
- if (isNaN(n) || n < 1) return "Must be a positive number";
234
+ if (isNaN(n) || n < 0)
235
+ return "Must be 0 (no timeout) or a positive number";
235
236
  return true;
236
237
  }
237
238
  });
@@ -1,19 +1,19 @@
1
1
  import {
2
2
  memoryConfigMenu
3
- } from "./chunk-BX3DRKTU.js";
3
+ } from "./chunk-XROT7L2F.js";
4
4
  import "./chunk-RZRT7NGT.js";
5
- import "./chunk-2HQVNQB7.js";
5
+ import "./chunk-J4N2ODQ5.js";
6
6
  import {
7
7
  findStatuslineHookPath,
8
8
  isStatuslineConfigured,
9
9
  saveStatuslineConfig,
10
10
  selectStatuslineComponents,
11
11
  writeStatuslineSettings
12
- } from "./chunk-GDZTU2Z4.js";
12
+ } from "./chunk-XQ5KAXKL.js";
13
13
  import {
14
14
  ALL_COMPONENT_IDS,
15
15
  DEFAULT_CONFIG
16
- } from "./chunk-PRIH34UB.js";
16
+ } from "./chunk-DHH6SRXV.js";
17
17
  import {
18
18
  ensureShellCompletion,
19
19
  hasShellCompletion,
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ensureStatusline
3
- } from "./chunk-GDZTU2Z4.js";
4
- import "./chunk-PRIH34UB.js";
3
+ } from "./chunk-XQ5KAXKL.js";
4
+ import "./chunk-DHH6SRXV.js";
5
5
  import {
6
6
  ensureShellCompletion
7
7
  } from "./chunk-DMJCO6LJ.js";
@@ -32,6 +32,7 @@ import {
32
32
  existsSync,
33
33
  unlinkSync,
34
34
  readdirSync,
35
+ readFileSync,
35
36
  statSync,
36
37
  mkdirSync,
37
38
  copyFileSync
@@ -255,11 +256,29 @@ async function devSyncCommand() {
255
256
  }
256
257
  const shortDest = cacheVersionDir.replace(homedir(), "~");
257
258
  log.step(`Syncing ${chalk.cyan(pluginSrcDir)} \u2192 ${chalk.dim(shortDest)}`);
259
+ const cachedHooksPath = join(cacheVersionDir, "hooks", "hooks.json");
260
+ const sourceHooksPath = join(pluginSrcDir, "hooks", "hooks.json");
261
+ let hooksEventsChanged = false;
262
+ try {
263
+ const cachedHooks = JSON.parse(readFileSync(cachedHooksPath, "utf-8"));
264
+ const sourceHooks = JSON.parse(readFileSync(sourceHooksPath, "utf-8"));
265
+ const cachedEvents = Object.keys(cachedHooks.hooks || {}).sort();
266
+ const sourceEvents = Object.keys(sourceHooks.hooks || {}).sort();
267
+ hooksEventsChanged = JSON.stringify(cachedEvents) !== JSON.stringify(sourceEvents);
268
+ } catch {
269
+ }
258
270
  const fileCount = { n: 0 };
259
271
  copyDirRecursive(pluginSrcDir, cacheVersionDir, fileCount);
260
- log.success(
261
- `Synced ${chalk.green(fileCount.n)} files. Restart Claude Code to apply changes.`
262
- );
272
+ if (hooksEventsChanged) {
273
+ log.success(`Synced ${chalk.green(fileCount.n)} files.`);
274
+ log.warn(
275
+ `hooks.json event types changed. Run ${chalk.bold("cf dev restart")} for Claude Code to register the new hook events.`
276
+ );
277
+ } else {
278
+ log.success(
279
+ `Synced ${chalk.green(fileCount.n)} files. Restart Claude Code to apply changes.`
280
+ );
281
+ }
263
282
  }
264
283
  async function devReinstall(path, label) {
265
284
  const state = getDevState();
@@ -288,6 +307,11 @@ async function devStatusCommand() {
288
307
  if (state) {
289
308
  log.info(`Dev mode: ${chalk.green("ON")}`);
290
309
  log.info(`Local path: ${chalk.cyan(state.localPath)}`);
310
+ if (!existsSync(state.localPath)) {
311
+ log.warn(
312
+ `Dev mode path no longer exists: ${state.localPath}. Run 'cf host <new-path>' to update.`
313
+ );
314
+ }
291
315
  log.dim(`Since: ${state.savedAt}`);
292
316
  } else {
293
317
  log.info(`Dev mode: ${chalk.yellow("OFF")}`);
@@ -0,0 +1,113 @@
1
+ import {
2
+ devStatePath,
3
+ pluginCachePath
4
+ } from "./chunk-TWKNGPBO.js";
5
+ import {
6
+ log
7
+ } from "./chunk-NREZK463.js";
8
+ import {
9
+ readJson
10
+ } from "./chunk-5UVDWG5L.js";
11
+
12
+ // src/commands/guide.ts
13
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from "fs";
14
+ import { join, resolve } from "path";
15
+ var GUIDE_TEMPLATE = `# Custom Guide for {{SKILL_NAME}}
16
+
17
+ <!-- Sections are optional \u2014 include only the ones you need. -->
18
+ <!-- This file is loaded automatically when the skill runs. -->
19
+
20
+ ## Before
21
+
22
+ <!-- Steps to execute BEFORE the skill's first step. -->
23
+ <!-- Example: run a linter, check prerequisites, set up context. -->
24
+
25
+ ## Rules
26
+
27
+ <!-- Additional rules to apply THROUGHOUT all steps of the skill. -->
28
+ <!-- Example: "always use TypeScript strict mode", "never modify tests in src/__tests__". -->
29
+
30
+ ## After
31
+
32
+ <!-- Steps to execute AFTER the skill's final step. -->
33
+ <!-- Example: run a formatter, update a changelog, notify a channel. -->
34
+ `;
35
+ function findPluginSkillsDir() {
36
+ const devState = readJson(devStatePath());
37
+ if (devState?.localPath) {
38
+ const dir = join(devState.localPath, "plugin", "skills");
39
+ if (existsSync(dir)) return dir;
40
+ }
41
+ const cachePath = pluginCachePath();
42
+ if (existsSync(cachePath)) {
43
+ try {
44
+ const versions = readdirSync(cachePath, { withFileTypes: true }).filter((e) => e.isDirectory() && /^\d/.test(e.name)).map((e) => e.name).sort((a, b) => b.localeCompare(a, void 0, { numeric: true }));
45
+ for (const ver of versions) {
46
+ const dir = join(cachePath, ver, "plugin", "skills");
47
+ if (existsSync(dir)) return dir;
48
+ }
49
+ } catch {
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+ function skillExists(skillsDir, skillName) {
55
+ return existsSync(join(skillsDir, skillName, "SKILL.md"));
56
+ }
57
+ function guideCreateCommand(skillName) {
58
+ if (!/^[a-z0-9-]+$/.test(skillName)) {
59
+ log.error(
60
+ `Invalid skill name "${skillName}". Use lowercase letters, numbers, and hyphens only.`
61
+ );
62
+ return;
63
+ }
64
+ const skillsDir = findPluginSkillsDir();
65
+ if (!skillsDir || !skillExists(skillsDir, skillName)) {
66
+ log.error(
67
+ `Skill "${skillName}" not found. Run "cf guide list" to see available skills.`
68
+ );
69
+ return;
70
+ }
71
+ const guideDir = resolve(".coding-friend", "skills", `${skillName}-custom`);
72
+ const guidePath = join(guideDir, "SKILL.md");
73
+ if (existsSync(guidePath)) {
74
+ log.warn(`Custom guide already exists: ${guidePath}`);
75
+ return;
76
+ }
77
+ mkdirSync(guideDir, { recursive: true });
78
+ writeFileSync(
79
+ guidePath,
80
+ GUIDE_TEMPLATE.replace(/\{\{SKILL_NAME\}\}/g, skillName)
81
+ );
82
+ log.success(`Created custom guide: ${guidePath}`);
83
+ log.dim("Edit the file to add your Before/Rules/After sections.");
84
+ }
85
+ function guideListCommand() {
86
+ const customDir = resolve(".coding-friend", "skills");
87
+ if (!existsSync(customDir)) {
88
+ log.dim(
89
+ "No custom guides found. Run `cf guide create <skill-name>` to create one."
90
+ );
91
+ return;
92
+ }
93
+ const entries = readdirSync(customDir, { withFileTypes: true }).filter(
94
+ (e) => e.isDirectory() && e.name.endsWith("-custom") && existsSync(join(customDir, e.name, "SKILL.md"))
95
+ ).map((e) => e.name.replace(/-custom$/, ""));
96
+ if (entries.length === 0) {
97
+ log.dim(
98
+ "No custom guides found. Run `cf guide create <skill-name>` to create one."
99
+ );
100
+ return;
101
+ }
102
+ const skillsDir = findPluginSkillsDir();
103
+ log.info(`Custom guides (${entries.length}):`);
104
+ for (const name of entries) {
105
+ const path = join(customDir, `${name}-custom`, "SKILL.md");
106
+ const exists = skillsDir != null && skillExists(skillsDir, name) ? "\u2714" : "\u26A0 skill not found";
107
+ log.info(` ${name} \u2192 ${path} (${exists})`);
108
+ }
109
+ }
110
+ export {
111
+ guideCreateCommand,
112
+ guideListCommand
113
+ };
@@ -3,8 +3,8 @@ import {
3
3
  } from "./chunk-RZRT7NGT.js";
4
4
  import {
5
5
  resolveDocsDir
6
- } from "./chunk-2HQVNQB7.js";
7
- import "./chunk-PRIH34UB.js";
6
+ } from "./chunk-J4N2ODQ5.js";
7
+ import "./chunk-DHH6SRXV.js";
8
8
  import {
9
9
  run,
10
10
  streamExec