@cliperhq/cliper 1.0.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 (68) hide show
  1. package/README.md +266 -0
  2. package/dist/commands/analyze.d.ts +6 -0
  3. package/dist/commands/analyze.d.ts.map +1 -0
  4. package/dist/commands/analyze.js +216 -0
  5. package/dist/commands/export.d.ts +6 -0
  6. package/dist/commands/export.d.ts.map +1 -0
  7. package/dist/commands/export.js +64 -0
  8. package/dist/commands/init.d.ts +7 -0
  9. package/dist/commands/init.d.ts.map +1 -0
  10. package/dist/commands/init.js +173 -0
  11. package/dist/commands/scope.d.ts +2 -0
  12. package/dist/commands/scope.d.ts.map +1 -0
  13. package/dist/commands/scope.js +124 -0
  14. package/dist/commands/status.d.ts +2 -0
  15. package/dist/commands/status.d.ts.map +1 -0
  16. package/dist/commands/status.js +100 -0
  17. package/dist/commands/sync.d.ts +6 -0
  18. package/dist/commands/sync.d.ts.map +1 -0
  19. package/dist/commands/sync.js +83 -0
  20. package/dist/context/builder.d.ts +19 -0
  21. package/dist/context/builder.d.ts.map +1 -0
  22. package/dist/context/builder.js +143 -0
  23. package/dist/gaps/detector.d.ts +10 -0
  24. package/dist/gaps/detector.d.ts.map +1 -0
  25. package/dist/gaps/detector.js +139 -0
  26. package/dist/index.d.ts +3 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +48 -0
  29. package/dist/resolver/urlFetcher.d.ts +9 -0
  30. package/dist/resolver/urlFetcher.d.ts.map +1 -0
  31. package/dist/resolver/urlFetcher.js +134 -0
  32. package/dist/scanner/dependencies.d.ts +14 -0
  33. package/dist/scanner/dependencies.d.ts.map +1 -0
  34. package/dist/scanner/dependencies.js +199 -0
  35. package/dist/scanner/fileContent.d.ts +8 -0
  36. package/dist/scanner/fileContent.d.ts.map +1 -0
  37. package/dist/scanner/fileContent.js +133 -0
  38. package/dist/scanner/fileTree.d.ts +2 -0
  39. package/dist/scanner/fileTree.d.ts.map +1 -0
  40. package/dist/scanner/fileTree.js +152 -0
  41. package/dist/scanner/gitContext.d.ts +19 -0
  42. package/dist/scanner/gitContext.d.ts.map +1 -0
  43. package/dist/scanner/gitContext.js +60 -0
  44. package/dist/scope/autoScope.d.ts +2 -0
  45. package/dist/scope/autoScope.d.ts.map +1 -0
  46. package/dist/scope/autoScope.js +226 -0
  47. package/dist/scope/config.d.ts +10 -0
  48. package/dist/scope/config.d.ts.map +1 -0
  49. package/dist/scope/config.js +71 -0
  50. package/index.js +2 -0
  51. package/package.json +37 -0
  52. package/src/commands/analyze.ts +201 -0
  53. package/src/commands/export.ts +33 -0
  54. package/src/commands/init.ts +174 -0
  55. package/src/commands/scope.ts +77 -0
  56. package/src/commands/status.ts +67 -0
  57. package/src/commands/sync.ts +51 -0
  58. package/src/context/builder.ts +178 -0
  59. package/src/gaps/detector.ts +131 -0
  60. package/src/index.ts +54 -0
  61. package/src/resolver/urlFetcher.ts +119 -0
  62. package/src/scanner/dependencies.ts +196 -0
  63. package/src/scanner/fileContent.ts +121 -0
  64. package/src/scanner/fileTree.ts +149 -0
  65. package/src/scanner/gitContext.ts +74 -0
  66. package/src/scope/autoScope.ts +182 -0
  67. package/src/scope/config.ts +39 -0
  68. package/tsconfig.json +19 -0
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.getCliperDir = getCliperDir;
37
+ exports.getScopeConfigPath = getScopeConfigPath;
38
+ exports.loadScopeConfig = loadScopeConfig;
39
+ exports.saveScopeConfig = saveScopeConfig;
40
+ const fs = __importStar(require("fs"));
41
+ const path = __importStar(require("path"));
42
+ const DEFAULT_CONFIG = {
43
+ active: [],
44
+ watched: [],
45
+ updatedAt: new Date().toISOString(),
46
+ };
47
+ function getCliperDir(projectRoot) {
48
+ return path.join(projectRoot, ".cliper");
49
+ }
50
+ function getScopeConfigPath(projectRoot) {
51
+ return path.join(getCliperDir(projectRoot), "scope.json");
52
+ }
53
+ function loadScopeConfig(projectRoot) {
54
+ const configPath = getScopeConfigPath(projectRoot);
55
+ if (!fs.existsSync(configPath))
56
+ return { ...DEFAULT_CONFIG };
57
+ try {
58
+ return JSON.parse(fs.readFileSync(configPath, "utf-8"));
59
+ }
60
+ catch {
61
+ return { ...DEFAULT_CONFIG };
62
+ }
63
+ }
64
+ function saveScopeConfig(projectRoot, config) {
65
+ const dir = getCliperDir(projectRoot);
66
+ if (!fs.existsSync(dir))
67
+ fs.mkdirSync(dir, { recursive: true });
68
+ config.updatedAt = new Date().toISOString();
69
+ fs.writeFileSync(getScopeConfigPath(projectRoot), JSON.stringify(config, null, 2));
70
+ }
71
+ //# sourceMappingURL=config.js.map
package/index.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ console.log("cliper v1.0.0");
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@cliperhq/cliper",
3
+ "version": "1.0.0",
4
+ "description": "Generate rich, AI-ready context documents from your codebase. Always fresh, always scoped — for Claude, ChatGPT, and beyond.",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "cliper": "./dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "dev": "ts-node src/index.ts",
12
+ "start": "node dist/index.js",
13
+ "lint": "eslint src/**/*.ts"
14
+ },
15
+ "dependencies": {
16
+ "chalk": "^5.3.0",
17
+ "cheerio": "^1.0.0",
18
+ "cli-table3": "^0.6.3",
19
+ "commander": "^12.0.0",
20
+ "glob": "^10.3.10",
21
+ "ignore": "^5.3.1",
22
+ "marked": "^12.0.0",
23
+ "node-fetch": "^3.3.2",
24
+ "ora": "^8.0.1",
25
+ "simple-git": "^3.22.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/cheerio": "^0.22.35",
29
+ "@types/node": "^20.12.7",
30
+ "ts-node": "^10.9.2",
31
+ "typescript": "^5.4.5"
32
+ },
33
+ "license": "ISC",
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ }
37
+ }
@@ -0,0 +1,201 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import chalk from "chalk";
4
+ import ora from "ora";
5
+ import { getCliperDir } from "../scope/config";
6
+
7
+ type Model = "claude" | "chatgpt";
8
+
9
+ interface AnalyzeOptions {
10
+ model: string;
11
+ }
12
+
13
+ const CLAUDE_SYSTEM = `You are an expert software architect analyzing a codebase context document.
14
+ Your job is to transform raw codebase data into a highly optimized prompt for a Claude AI coding session.
15
+
16
+ Claude thinks best when given:
17
+ - Clear project narrative (what it is, why it exists)
18
+ - Explicit constraints and existing decisions to respect
19
+ - The specific area of focus with surrounding context
20
+ - What NOT to change or break
21
+ - Open questions or uncertainties the developer has
22
+
23
+ Your output must be a ready-to-paste prompt. No preamble. No explanation. Just the prompt itself.`;
24
+
25
+ const CHATGPT_SYSTEM = `You are an expert software architect analyzing a codebase context document.
26
+ Your job is to transform raw codebase data into a highly optimized prompt for a ChatGPT coding session.
27
+
28
+ ChatGPT works best when given:
29
+ - Numbered, structured context sections
30
+ - Explicit file paths and code blocks
31
+ - Clear task definition separated from context
32
+ - Constraints listed as bullet points
33
+ - A direct question or task at the end
34
+
35
+ Your output must be a ready-to-paste prompt. No preamble. No explanation. Just the prompt itself.`;
36
+
37
+ const CLAUDE_USER = (contextDoc: string) => `Here is the raw codebase context document generated by Cliper:
38
+
39
+ <context>
40
+ ${contextDoc}
41
+ </context>
42
+
43
+ Transform this into an optimized Claude prompt following these rules:
44
+
45
+ CRITICAL ACCURACY RULES:
46
+ - Never expand acronyms unless the context doc explicitly defines them. If you see "SPEL" and the doc doesn't say what it stands for, write "SPEL" — do not invent an expansion.
47
+ - Never infer what a framework "is based on" from inspiration references. "Inspired by Anchor for Solana" does NOT mean the project runs on Solana.
48
+ - Include ALL macros and annotations found in the source code, not just the most common ones. If you see #[require_admin], include it.
49
+ - If a file was truncated, note that there may be additional macros or patterns not visible.
50
+
51
+ FORMAT RULES:
52
+ 1. Opens with a concise project narrative (2-3 sentences max) — only state facts explicitly in the context doc
53
+ 2. Summarizes the current branch focus and recent work
54
+ 3. Lists key architectural decisions already made that must be respected
55
+ 4. Highlights the most important files and their roles
56
+ 5. Lists ALL macros/annotations found across all files
57
+ 6. Surfaces any detected gaps the developer should be aware of
58
+ 7. Ends with: "## What I need help with today:"
59
+
60
+ Make it feel like a natural briefing. Never invent details not present in the context doc.`;
61
+
62
+ const CHATGPT_USER = (contextDoc: string) => `Here is the raw codebase context document generated by Cliper:
63
+
64
+ ${contextDoc}
65
+
66
+ Transform this into an optimized chatgpt prompt following these rules:
67
+
68
+ CRITICAL ACCURACY RULES:
69
+ - Never expand acronyms unless the context doc explicitly defines them. If you see "SPEL" and the doc doesn't say what it stands for, write "SPEL" — do not invent an expansion.
70
+ - Never infer what a framework "is based on" from inspiration references. "Inspired by Anchor for Solana" does NOT mean the project runs on Solana.
71
+ - Include ALL macros and annotations found in the source code, not just the most common ones. If you see #[require_admin], include it.
72
+ - If a file was truncated, note that there may be additional macros or patterns not visible.
73
+
74
+ FORMAT RULES:
75
+ 1. Starts with "## Project Context" as a header
76
+ 2. Uses numbered sections for: Project Overview, Tech Stack, Current Branch & Focus, Key Files, Constraints & Decisions, Known Gaps
77
+ 3. Uses bullet points and code blocks for file paths and snippets
78
+ 4. Keeps each section concise — no more than 5 bullet points per section
79
+ 5. Ends with "## Task:" followed by a blank line for the developer to fill in
80
+
81
+ Format it for maximum clarity and scannability. ChatGPT responds best to structured, explicitly labeled context.`;
82
+
83
+ async function callAnthropicAPI(contextDoc: string): Promise<string> {
84
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
85
+ method: "POST",
86
+ headers: {
87
+ "Content-Type": "application/json",
88
+ "x-api-key": process.env.ANTHROPIC_API_KEY ?? "",
89
+ "anthropic-version": "2023-06-01",
90
+ },
91
+ body: JSON.stringify({
92
+ model: "claude-sonnet-4-20250514",
93
+ max_tokens: 2000,
94
+ system: CLAUDE_SYSTEM,
95
+ messages: [{ role: "user", content: CLAUDE_USER(contextDoc) }],
96
+ }),
97
+ });
98
+
99
+ if (!response.ok) {
100
+ const err = await response.text();
101
+ throw new Error(`Anthropic API error: ${response.status} — ${err}`);
102
+ }
103
+
104
+ const data = await response.json() as any;
105
+ return data.content?.[0]?.text ?? "";
106
+ }
107
+
108
+ async function callOpenAIAPI(contextDoc: string): Promise<string> {
109
+ const response = await fetch("https://api.openai.com/v1/chat/completions", {
110
+ method: "POST",
111
+ headers: {
112
+ "Content-Type": "application/json",
113
+ "Authorization": `Bearer ${process.env.OPENAI_API_KEY ?? ""}`,
114
+ },
115
+ body: JSON.stringify({
116
+ model: "gpt-4o",
117
+ max_tokens: 2000,
118
+ messages: [
119
+ { role: "system", content: CHATGPT_SYSTEM },
120
+ { role: "user", content: CHATGPT_USER(contextDoc) },
121
+ ],
122
+ }),
123
+ });
124
+
125
+ if (!response.ok) {
126
+ const err = await response.text();
127
+ throw new Error(`OpenAI API error: ${response.status} — ${err}`);
128
+ }
129
+
130
+ const data = await response.json() as any;
131
+ return data.choices?.[0]?.message?.content ?? "";
132
+ }
133
+
134
+ export async function analyzeCommand(options: AnalyzeOptions): Promise<void> {
135
+ const projectRoot = process.cwd();
136
+ const cliperDir = getCliperDir(projectRoot);
137
+ const contextPath = path.join(cliperDir, "context.md");
138
+
139
+ // Check context doc exists
140
+ if (!fs.existsSync(contextPath)) {
141
+ console.error(chalk.red("\n No context doc found. Run cliper init first.\n"));
142
+ process.exit(1);
143
+ }
144
+
145
+ const contextDoc = fs.readFileSync(contextPath, "utf-8");
146
+ const model = options.model.toLowerCase() as Model;
147
+
148
+ // Validate model choice
149
+ if (!["claude", "chatgpt"].includes(model)) {
150
+ console.error(chalk.red(`\n Unknown model: ${options.model}`));
151
+ console.error(chalk.gray(" Usage: cliper analyze --model claude"));
152
+ console.error(chalk.gray(" cliper analyze --model chatgpt\n"));
153
+ process.exit(1);
154
+ }
155
+
156
+ // Check API key
157
+ if (model === "claude" && !process.env.ANTHROPIC_API_KEY) {
158
+ console.error(chalk.red("\n ANTHROPIC_API_KEY not set."));
159
+ console.error(chalk.gray(" Export it first: export ANTHROPIC_API_KEY=your_key_here\n"));
160
+ process.exit(1);
161
+ }
162
+
163
+ if (model === "chatgpt" && !process.env.OPENAI_API_KEY) {
164
+ console.error(chalk.red("\n OPENAI_API_KEY not set."));
165
+ console.error(chalk.gray(" Export it first: export OPENAI_API_KEY=your_key_here\n"));
166
+ process.exit(1);
167
+ }
168
+
169
+ console.log(chalk.bold.cyan(`\n cliper analyze — ${model}\n`));
170
+
171
+ const spinner = ora(`Analyzing context doc with ${model === "claude" ? "Claude" : "ChatGPT"}...`).start();
172
+
173
+ try {
174
+ let prompt: string;
175
+
176
+ if (model === "claude") {
177
+ prompt = await callAnthropicAPI(contextDoc);
178
+ const outputPath = path.join(cliperDir, "prompt-claude.md");
179
+ fs.writeFileSync(outputPath, prompt, "utf-8");
180
+ spinner.succeed(chalk.green("Claude prompt generated"));
181
+ console.log(chalk.gray(`\n Saved: ${outputPath}\n`));
182
+ console.log(chalk.cyan(" Copy to clipboard:"));
183
+ console.log(chalk.white(" cliper analyze --model claude | pbcopy\n"));
184
+ console.log(chalk.cyan(" Or open directly:"));
185
+ console.log(chalk.white(` cat ${outputPath} | pbcopy\n`));
186
+ } else {
187
+ prompt = await callOpenAIAPI(contextDoc);
188
+ const outputPath = path.join(cliperDir, "prompt-gpt.md");
189
+ fs.writeFileSync(outputPath, prompt, "utf-8");
190
+ spinner.succeed(chalk.green("ChatGPT prompt generated"));
191
+ console.log(chalk.gray(`\n Saved: ${outputPath}\n`));
192
+ console.log(chalk.cyan(" Copy to clipboard:"));
193
+ console.log(chalk.white(` cat ${outputPath} | pbcopy\n`));
194
+ }
195
+
196
+ } catch (err: any) {
197
+ spinner.fail(chalk.red("Analysis failed"));
198
+ console.error(chalk.red(`\n ${err.message}\n`));
199
+ process.exit(1);
200
+ }
201
+ }
@@ -0,0 +1,33 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import chalk from "chalk";
4
+ import { getCliperDir } from "../scope/config";
5
+
6
+ interface ExportOptions {
7
+ format: string;
8
+ }
9
+
10
+ export async function exportCommand(options: ExportOptions): Promise<void> {
11
+ const projectRoot = process.cwd();
12
+ const contextPath = path.join(getCliperDir(projectRoot), "context.md");
13
+
14
+ if (!fs.existsSync(contextPath)) {
15
+ console.error(chalk.red("\n No context doc found. Run cliper init first.\n"));
16
+ process.exit(1);
17
+ }
18
+
19
+ let content = fs.readFileSync(contextPath, "utf-8");
20
+
21
+ if (options.format === "txt") {
22
+ // Strip markdown formatting for plain text output
23
+ content = content
24
+ .replace(/```[\w]*\n/g, "")
25
+ .replace(/```/g, "")
26
+ .replace(/#{1,6}\s/g, "")
27
+ .replace(/\*\*/g, "")
28
+ .replace(/`/g, "");
29
+ }
30
+
31
+ // Write to stdout — designed to be piped
32
+ process.stdout.write(content);
33
+ }
@@ -0,0 +1,174 @@
1
+ import { buildDependencyMap, formatDependencyMap } from "../scanner/dependencies";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import simpleGit from "simple-git";
5
+ import chalk from "chalk";
6
+ import ora from "ora";
7
+ import { autoDetectScope } from "../scope/autoScope";
8
+ import { loadScopeConfig, saveScopeConfig, getCliperDir } from "../scope/config";
9
+ import { generateFileTree } from "../scanner/fileTree";
10
+ import { extractFileContents } from "../scanner/fileContent";
11
+ import { getGitContext } from "../scanner/gitContext";
12
+ import { resolveBlockedReferences } from "../resolver/urlFetcher";
13
+ import { detectGaps } from "../gaps/detector";
14
+ import { buildContextDoc } from "../context/builder";
15
+
16
+
17
+
18
+ interface InitOptions {
19
+ path: string;
20
+ maxFileSize?: number;
21
+ }
22
+
23
+
24
+ export async function initCommand(options: InitOptions): Promise<void> {
25
+ const projectRoot = path.resolve(options.path);
26
+
27
+ if (!fs.existsSync(projectRoot)) {
28
+ console.error(chalk.red(`Project path not found: ${projectRoot}`));
29
+ process.exit(1);
30
+ }
31
+
32
+ console.log(chalk.bold.cyan("\n cliper init\n"));
33
+ console.log(chalk.gray(` Project: ${projectRoot}\n`));
34
+
35
+ // Step 1: Load or create scope config
36
+ const spinner = ora("Detecting scope from git activity...").start();
37
+ let scopeConfig = loadScopeConfig(projectRoot);
38
+
39
+ // Always re-run auto-detection and merge with any manual additions
40
+ const autoScope = await autoDetectScope(projectRoot);
41
+ const manualAdditions = scopeConfig.active.filter((p) => !autoScope.includes(p));
42
+ scopeConfig.active = [...new Set([...autoScope, ...manualAdditions])];
43
+ saveScopeConfig(projectRoot, scopeConfig);
44
+ spinner.succeed(
45
+ chalk.green(`Scope detected: ${scopeConfig.active.length} paths active, ${scopeConfig.watched.length} watched`)
46
+ );
47
+
48
+ // Step 2: Generate file tree
49
+ const treeSpinner = ora("Building annotated file tree...").start();
50
+ const fileTree = generateFileTree(projectRoot, scopeConfig.active, scopeConfig.watched);
51
+ treeSpinner.succeed(chalk.green("File tree built"));
52
+
53
+ // Step 3: Extract file contents
54
+ const contentSpinner = ora("Extracting file contents within scope...").start();
55
+ const files = await extractFileContents(
56
+ projectRoot,
57
+ scopeConfig.active,
58
+ scopeConfig.watched,
59
+ options.maxFileSize ?? 50
60
+ );
61
+
62
+ contentSpinner.succeed(chalk.green(`Extracted ${files.length} files`));
63
+
64
+ // Step 4: Git context
65
+ const gitSpinner = ora("Reading git context...").start();
66
+ const gitContext = await getGitContext(projectRoot);
67
+ gitSpinner.succeed(
68
+ gitContext.isGitRepo
69
+ ? chalk.green(`Git: ${gitContext.branch} — ${gitContext.lastCommit?.message ?? "no commits"}`)
70
+ : chalk.yellow("Not a git repository")
71
+ );
72
+
73
+ // Step 5: Resolve blocked references
74
+ const refSpinner = ora("Fetching blocked external references...").start();
75
+ const references = await resolveBlockedReferences(projectRoot);
76
+ const fetched = references.filter((r) => r.status === "fetched").length;
77
+ const failed = references.filter((r) => r.status === "failed").length;
78
+ refSpinner.succeed(chalk.green(`References: ${fetched} fetched, ${failed} failed`));
79
+
80
+
81
+ // Step 6: Detect gaps
82
+
83
+
84
+
85
+ const gapSpinner = ora("Detecting gaps and undocumented patterns...").start();
86
+ const gaps = detectGaps(files, projectRoot);
87
+ gapSpinner.succeed(
88
+ gaps.length > 0
89
+ ? chalk.yellow(`Found ${gaps.length} gaps (${gaps.filter((g) => g.severity === "high").length} high priority)`)
90
+ : chalk.green("No significant gaps detected")
91
+ );
92
+ const depSpinner = ora("Mapping dependencies...").start();
93
+ const dependencyMap = buildDependencyMap(files);
94
+ depSpinner.succeed(chalk.green(`Dependency map: ${dependencyMap.edges.length} edges, ${dependencyMap.externalPackages.length} external packages`));
95
+
96
+ // Step 7: Build context doc
97
+ const buildSpinner = ora("Building context document...").start();
98
+ const projectName = path.basename(projectRoot);
99
+ const contextDoc = buildContextDoc({
100
+ projectRoot,
101
+ projectName,
102
+ activeScope: scopeConfig.active,
103
+ watchedScope: scopeConfig.watched,
104
+ fileTree,
105
+ files,
106
+ gitContext,
107
+ references,
108
+ gaps,
109
+ generatedAt: new Date().toISOString(),
110
+ dependencyMap: formatDependencyMap(dependencyMap),
111
+ });
112
+
113
+ // Step 8: Write to disk
114
+ const cliperDir = getCliperDir(projectRoot);
115
+ if (!fs.existsSync(cliperDir)) fs.mkdirSync(cliperDir, { recursive: true });
116
+ const contextPath = path.join(cliperDir, "context.md");
117
+ fs.writeFileSync(contextPath, contextDoc, "utf-8");
118
+ buildSpinner.succeed(chalk.green("Context document built"));
119
+
120
+ // Auto-manage .gitignore — add anything cliper introduces that shouldn't be committed
121
+ const gitignorePath = path.join(projectRoot, ".gitignore");
122
+
123
+ const entriesToEnsure: Array<{ check: string; line: string }> = [
124
+ { check: "node_modules", line: "node_modules/" },
125
+ { check: "package-lock.json", line: "package-lock.json" },
126
+ { check: ".cliper/cache", line: ".cliper/cache/" },
127
+ { check: ".cliper/prompt-", line: ".cliper/prompt-*.md" },
128
+ ];
129
+
130
+ let gitignoreContent = fs.existsSync(gitignorePath)
131
+ ? fs.readFileSync(gitignorePath, "utf-8")
132
+ : "";
133
+
134
+ const missingEntries = entriesToEnsure.filter((e) => !gitignoreContent.includes(e.check));
135
+
136
+ if (missingEntries.length > 0) {
137
+ const block = "\n# Cliper — auto-managed\n" + missingEntries.map((e) => e.line).join("\n") + "\n";
138
+ if (fs.existsSync(gitignorePath)) {
139
+ fs.appendFileSync(gitignorePath, block);
140
+ } else {
141
+ fs.writeFileSync(gitignorePath, block.trimStart());
142
+ }
143
+ }
144
+
145
+ // Remove node_modules from git tracking if accidentally staged
146
+ try {
147
+ const gitInstance = simpleGit(projectRoot);
148
+ const tracked = await gitInstance.raw(["ls-files", "node_modules"]);
149
+ if (tracked.trim().length > 0) {
150
+ await gitInstance.raw(["rm", "-r", "--cached", "node_modules/"]);
151
+ console.log(chalk.yellow(" ⚡ Removed node_modules from git tracking"));
152
+ }
153
+ const lockTracked = await gitInstance.raw(["ls-files", "package-lock.json"]);
154
+ if (lockTracked.trim().length > 0) {
155
+ await gitInstance.raw(["rm", "--cached", "package-lock.json"]);
156
+ console.log(chalk.yellow(" ⚡ Removed package-lock.json from git tracking"));
157
+ }
158
+ } catch {
159
+ // Not a git repo or already clean — skip silently
160
+ }
161
+
162
+ // Summary
163
+ const sizeKB = Math.round(Buffer.byteLength(contextDoc, "utf-8") / 1024);
164
+ const estimatedTokens = Math.round(contextDoc.length / 4);
165
+
166
+ console.log(chalk.bold.green("\n ✓ Context document ready\n"));
167
+ console.log(chalk.gray(` Location: ${contextPath}`));
168
+ console.log(chalk.gray(` Size: ${sizeKB}KB (~${estimatedTokens.toLocaleString()} tokens)`));
169
+ console.log(chalk.gray(` Files: ${files.length} files in context`));
170
+ console.log(chalk.gray(` Gaps: ${gaps.length} detected\n`));
171
+ console.log(chalk.cyan(" Copy to clipboard:"));
172
+ console.log(chalk.white(" cliper export | pbcopy # macOS"));
173
+ console.log(chalk.white(" cliper export | xclip # Linux\n"));
174
+ }
@@ -0,0 +1,77 @@
1
+ import * as path from "path";
2
+ import chalk from "chalk";
3
+ import { loadScopeConfig, saveScopeConfig } from "../scope/config";
4
+
5
+ export async function scopeCommand(action: string, scopePath?: string): Promise<void> {
6
+ const projectRoot = process.cwd();
7
+ const config = loadScopeConfig(projectRoot);
8
+
9
+ switch (action) {
10
+ case "add": {
11
+ if (!scopePath) { console.error(chalk.red(" Please provide a path.")); process.exit(1); }
12
+ const normalized = path.normalize(scopePath);
13
+ if (!config.active.includes(normalized)) {
14
+ config.active.push(normalized);
15
+ saveScopeConfig(projectRoot, config);
16
+ console.log(chalk.green(`\n ✓ Added to active scope: ${normalized}\n`));
17
+ console.log(chalk.gray(" Run cliper sync to update the context doc.\n"));
18
+ } else {
19
+ console.log(chalk.yellow(`\n Already in scope: ${normalized}\n`));
20
+ }
21
+ break;
22
+ }
23
+
24
+ case "watch": {
25
+ if (!scopePath) { console.error(chalk.red(" Please provide a path.")); process.exit(1); }
26
+ const normalized = path.normalize(scopePath);
27
+ if (config.watched.length >= 15) {
28
+ console.log(chalk.yellow("\n Watch list is capped at 15 files. Remove one first:\n"));
29
+ console.log(chalk.gray(" cliper scope remove <path>\n"));
30
+ process.exit(1);
31
+ }
32
+ if (!config.watched.includes(normalized)) {
33
+ config.watched.push(normalized);
34
+ saveScopeConfig(projectRoot, config);
35
+ console.log(chalk.green(`\n ✓ Added to watch list: ${normalized}\n`));
36
+ } else {
37
+ console.log(chalk.yellow(`\n Already watched: ${normalized}\n`));
38
+ }
39
+ break;
40
+ }
41
+
42
+ case "remove": {
43
+ if (!scopePath) { console.error(chalk.red(" Please provide a path.")); process.exit(1); }
44
+ const normalized = path.normalize(scopePath);
45
+ config.active = config.active.filter((p) => p !== normalized);
46
+ config.watched = config.watched.filter((p) => p !== normalized);
47
+ saveScopeConfig(projectRoot, config);
48
+ console.log(chalk.green(`\n ✓ Removed from scope: ${normalized}\n`));
49
+ break;
50
+ }
51
+
52
+ case "list": {
53
+ console.log(chalk.bold.cyan("\n Current Scope\n"));
54
+ if (config.active.length === 0 && config.watched.length === 0) {
55
+ console.log(chalk.gray(" No scope configured. Run cliper init to auto-detect.\n"));
56
+ break;
57
+ }
58
+ if (config.active.length > 0) {
59
+ console.log(chalk.bold(" Active scope:"));
60
+ for (const p of config.active) console.log(chalk.white(` • ${p}`));
61
+ }
62
+ if (config.watched.length > 0) {
63
+ console.log(chalk.bold("\n Watch list:"));
64
+ for (const p of config.watched) console.log(chalk.white(` • ${p}`));
65
+ console.log(chalk.gray(`\n (${config.watched.length}/15 watch slots used)`));
66
+ }
67
+ console.log();
68
+ break;
69
+ }
70
+
71
+ default: {
72
+ console.error(chalk.red(`\n Unknown action: ${action}`));
73
+ console.log(chalk.gray(" Usage: cliper scope <add|remove|watch|list> [path]\n"));
74
+ process.exit(1);
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,67 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import chalk from "chalk";
4
+ import { loadScopeConfig, getCliperDir } from "../scope/config";
5
+ import { getGitContext } from "../scanner/gitContext";
6
+
7
+ export async function statusCommand(): Promise<void> {
8
+ const projectRoot = process.cwd();
9
+ const cliperDir = getCliperDir(projectRoot);
10
+ const contextPath = path.join(cliperDir, "context.md");
11
+ const config = loadScopeConfig(projectRoot);
12
+
13
+ console.log(chalk.bold.cyan("\n cliper status\n"));
14
+
15
+ // Context doc status
16
+ if (!fs.existsSync(contextPath)) {
17
+ console.log(chalk.red(" ✗ No context doc found"));
18
+ console.log(chalk.gray(" Run: cliper init\n"));
19
+ return;
20
+ }
21
+
22
+ const stat = fs.statSync(contextPath);
23
+ const ageMs = Date.now() - stat.mtime.getTime();
24
+ const ageHours = Math.floor(ageMs / 3600000);
25
+ const ageMins = Math.floor(ageMs / 60000);
26
+ const ageStr = ageHours > 0 ? `${ageHours}h ago` : `${ageMins}m ago`;
27
+ const sizeKB = Math.round(stat.size / 1024);
28
+ const isFresh = ageMs < 3600000; // < 1 hour
29
+
30
+ console.log(
31
+ isFresh
32
+ ? chalk.green(` ✓ Context doc is fresh`)
33
+ : chalk.yellow(` ⚡ Context doc may be stale`)
34
+ );
35
+ console.log(chalk.gray(` Last updated: ${ageStr} | Size: ${sizeKB}KB`));
36
+ console.log(chalk.gray(` Path: ${contextPath}`));
37
+
38
+ // Scope status
39
+ console.log(chalk.bold("\n Scope:"));
40
+ if (config.active.length === 0) {
41
+ console.log(chalk.gray(" No active scope. Run cliper init to auto-detect."));
42
+ } else {
43
+ for (const p of config.active) console.log(chalk.white(` • ${p} `) + chalk.cyan("[active]"));
44
+ for (const p of config.watched) console.log(chalk.white(` • ${p} `) + chalk.blue("[watched]"));
45
+ }
46
+
47
+ // Git status
48
+ const git = await getGitContext(projectRoot);
49
+ if (git.isGitRepo) {
50
+ console.log(chalk.bold("\n Git:"));
51
+ console.log(chalk.gray(` Branch: ${git.branch}`));
52
+ if (git.lastCommit) {
53
+ console.log(chalk.gray(` Latest: ${git.lastCommit.hash} — ${git.lastCommit.message} (${git.lastCommit.timeAgo})`));
54
+ }
55
+ if (git.uncommittedChanges.length > 0) {
56
+ console.log(chalk.yellow(` Uncommitted: ${git.uncommittedChanges.length} files changed`));
57
+ if (ageMs > 600000) {
58
+ // Context is older than 10 mins and there are uncommitted changes
59
+ console.log(chalk.yellow("\n ⚡ Changes detected since last context update."));
60
+ console.log(chalk.gray(" Run: cliper sync\n"));
61
+ return;
62
+ }
63
+ }
64
+ }
65
+
66
+ console.log();
67
+ }