diff --git a/README.ko.md b/README.ko.md
index 6f29ac8..2e653fa 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -312,7 +312,7 @@ args = ["@pleaseai/csp", "mcp"]
## 서브 에이전트 설정
-Claude Code, Gemini CLI, Cursor, OpenCode, GitHub Copilot CLI, Kiro는 모두 전용 `csp` 검색 서브 에이전트를 지원합니다. 프로젝트 루트에서 `csp init`을 한 번 실행하세요.
+Claude Code, Gemini CLI, Cursor, OpenCode, GitHub Copilot CLI, Kiro, Antigravity, Command Code, Pi, Reasonix는 모두 전용 `csp` 검색 서브 에이전트를 지원합니다. 프로젝트 루트에서 `csp init`을 한 번 실행하세요.
```bash
csp init # Claude Code → .claude/agents/csp-search.md
@@ -321,6 +321,10 @@ csp init --agent cursor # Cursor → .cursor/agents/csp-search.md
csp init --agent opencode # OpenCode → .opencode/agents/csp-search.md
csp init --agent copilot # Copilot CLI → .github/agents/csp-search.md
csp init --agent kiro # Kiro → .kiro/agents/csp-search.md
+csp init --agent antigravity # Antigravity → .antigravity/agents/csp-search.md
+csp init --agent commandcode # Command Code → .commandcode/agents/csp-search.md
+csp init --agent pi # Pi → .pi/agents/csp-search.md
+csp init --agent reasonix # Reasonix → .reasonix/agents/csp-search.md
```
`csp`가 `$PATH`에 없다면 명령 앞에 `bunx @pleaseai/csp`를 붙이세요.
@@ -363,21 +367,45 @@ csp savings --verbose # 호출 유형별 분해 포함
```
```
- csp Token Savings
- ════════════════════════════════════════════════════════════════
- Period Calls Savings
- ────────────────────────────────────────────────────────────────
- Today 42 [███████████████░] ~58.4k tokens (95%)
- Last 7 days 287 [██████████████░░] ~312.4k tokens (90%)
- All time 1.4k [██████████████░░] ~1.2M tokens (89%)
+ Csp Token Savings
+ ════════════════════════════════════════════════════════════════════════
+
+ Total saved: ~1.2M tokens (89%)
+ Total calls: 1.4k
+ Efficiency: █████████████████████░░░ 89%
+
+ By Period
+ ────────────────────────────────────────────────────────────────────────
+ Period Calls Saved Ratio
+ ────────────────────────────────────────────────────────────────────────
+ Today 42 ~58.4k tokens ███████████████████████░ 95%
+ Last 7 days 287 ~312.4k tokens █████████████████████░░░ 90%
+ All time 1.4k ~1.2M tokens █████████████████████░░░ 89%
```
절약량 계산: 각 호출마다 반환된 청크가 속한 파일들의 총 문자 수와 반환된 스니펫의 문자 수를 기록합니다. 절약된 토큰 추정치는 `(파일 문자 수 − 스니펫 문자 수) / 4` (1토큰 ≈ 4문자). 이는 보수적인 추정으로, 기준선은 "에이전트가 매칭된 파일을 통째로 읽는다"는 일반적인 코딩 에이전트 동작입니다.
+stdout이 컬러를 지원하는 TTY일 때 출력에 색이 입혀집니다(`NO_COLOR`, `dumb` 터미널, 파이프 연결 시에는 비활성화). `--verbose`를 주면 "By Call Type" 분해가 추가됩니다.
+
통계는 `~/.csp/savings.jsonl`에 저장됩니다.
+
+캐시 비우기
+
+`csp clear`는 캐시된 데이터를 삭제합니다.
+
+```bash
+csp clear savings # ~/.csp/savings.jsonl 삭제
+csp clear all # 현재는 savings와 동일
+csp clear index # 안내만 출력 (아래 참고)
+```
+
+인덱스 영속화는 아직 연결되지 않았고, 저장 모델(repo-local `.csp/` vs 글로벌 캐시)도 미결이라 현재 `clear index`는 비울 대상이 없습니다. `csp index -o <경로>`는 지정한 경로에만 기록하므로 해당 디렉토리는 직접 삭제하세요.
+
+
+
라이브러리 사용
diff --git a/README.md b/README.md
index ccd99ec..1e4b97e 100644
--- a/README.md
+++ b/README.md
@@ -312,7 +312,7 @@ By default the MCP server indexes only code files. To also index documentation,
## Sub-agent setup
-Claude Code, Gemini CLI, Cursor, OpenCode, GitHub Copilot CLI, and Kiro all support a dedicated `csp` search sub-agent. Run `csp init` once in your project root:
+Claude Code, Gemini CLI, Cursor, OpenCode, GitHub Copilot CLI, Kiro, Antigravity, Command Code, Pi, and Reasonix all support a dedicated `csp` search sub-agent. Run `csp init` once in your project root:
```bash
csp init # Claude Code → .claude/agents/csp-search.md
@@ -321,6 +321,10 @@ csp init --agent cursor # Cursor → .cursor/agents/csp-search.md
csp init --agent opencode # OpenCode → .opencode/agents/csp-search.md
csp init --agent copilot # Copilot CLI → .github/agents/csp-search.md
csp init --agent kiro # Kiro → .kiro/agents/csp-search.md
+csp init --agent antigravity # Antigravity → .antigravity/agents/csp-search.md
+csp init --agent commandcode # Command Code → .commandcode/agents/csp-search.md
+csp init --agent pi # Pi → .pi/agents/csp-search.md
+csp init --agent reasonix # Reasonix → .reasonix/agents/csp-search.md
```
If `csp` is not on `$PATH`, prefix the command with `bunx @pleaseai/csp`.
@@ -363,21 +367,45 @@ csp savings --verbose # also show breakdown by call type
```
```
- csp Token Savings
- ════════════════════════════════════════════════════════════════
- Period Calls Savings
- ────────────────────────────────────────────────────────────────
- Today 42 [███████████████░] ~58.4k tokens (95%)
- Last 7 days 287 [██████████████░░] ~312.4k tokens (90%)
- All time 1.4k [██████████████░░] ~1.2M tokens (89%)
+ Csp Token Savings
+ ════════════════════════════════════════════════════════════════════════
+
+ Total saved: ~1.2M tokens (89%)
+ Total calls: 1.4k
+ Efficiency: █████████████████████░░░ 89%
+
+ By Period
+ ────────────────────────────────────────────────────────────────────────
+ Period Calls Saved Ratio
+ ────────────────────────────────────────────────────────────────────────
+ Today 42 ~58.4k tokens ███████████████████████░ 95%
+ Last 7 days 287 ~312.4k tokens █████████████████████░░░ 90%
+ All time 1.4k ~1.2M tokens █████████████████████░░░ 89%
```
Savings are calculated as follows: for each call, `csp` records the total character count of the unique files containing returned chunks and the character count of the snippets returned. Estimated tokens saved is `(file chars − snippet chars) / 4` (4 chars per token). This is a conservative estimate: the baseline is reading matched files in full, which is how coding agents often explore unfamiliar code.
+Output is colorized when stdout is a color-capable TTY (suppressed under `NO_COLOR`, a `dumb` terminal, or when piped). `--verbose` adds a "By Call Type" breakdown.
+
Stats are stored in `~/.csp/savings.jsonl`.
+
+Clear
+
+`csp clear` removes cached data:
+
+```bash
+csp clear savings # delete ~/.csp/savings.jsonl
+csp clear all # currently the same as `savings`
+csp clear index # prints a note (see below)
+```
+
+Index persistence is not wired up yet, and the storage model — repo-local `.csp/` vs a global cache — is still undecided, so `clear index` has nothing to remove for now. `csp index -o ` writes only to the path you pass; delete those directories yourself.
+
+
+
Library usage
diff --git a/src/agents/antigravity.md b/src/agents/antigravity.md
new file mode 100644
index 0000000..adaaeec
--- /dev/null
+++ b/src/agents/antigravity.md
@@ -0,0 +1,58 @@
+---
+name: csp-search
+description: Code search agent for exploring any codebase. Use for finding code by intent, locating implementations, understanding how something works, or discovering related code. Prefer over run_shell_command/read_file for any semantic or exploratory question.
+tools:
+ - run_shell_command
+ - read_file
+---
+
+Use `csp search` to find code by describing what it does or naming a symbol/identifier, instead of grep:
+
+```bash
+csp search "authentication flow" ./my-project
+csp search "save_pretrained" ./my-project
+csp search "save model to disk" ./my-project --top-k 10
+```
+
+If you anticipate doing more than one search, use `csp index` to create an index.
+
+```bash
+csp index ./my-project -o my_index
+```
+
+You can then reuse this index later on:
+
+```bash
+csp search "save_pretrained" --index my_index
+```
+
+An index is not automatically updated, so if the code changes significantly, reindex. If you notice stale results while resolving searches to files, reindex.
+
+Use `--content docs` to search documentation and prose, `--content config` for config files (yaml, toml, etc.), or `--content all` to search code, docs, and config:
+
+```bash
+csp search "deployment guide" ./my-project --content docs
+csp search "database host port" ./my-project --content config
+csp search "authentication" ./my-project --content all
+```
+
+Use `csp find-related` to discover code similar to a known location (pass `filePath` and `line` from a prior search result):
+
+```bash
+csp find-related src/auth.ts 42 ./my-project
+```
+
+Like search, `find-related` also accepts an `--index` argument.
+
+`path` defaults to the current directory when omitted; git URLs are accepted.
+
+If `csp` is not on `$PATH`, use `bunx @pleaseai/csp` in its place.
+
+### Workflow
+
+1. Index the repo using `csp index -o cached_index`.
+2. Start with `csp search` to find relevant chunks. Pass the index to achieve results faster.
+3. Use `--content docs` for documentation, `--content config` for config files, or `--content all` for everything.
+4. Inspect full files only when the returned chunk does not give enough context.
+5. Optionally use `csp find-related` with a promising result's `filePath` and `line` to discover related implementations.
+6. Use grep only when you need exhaustive literal matches or quick confirmation of an exact string.
\ No newline at end of file
diff --git a/src/agents/commandcode.md b/src/agents/commandcode.md
new file mode 100644
index 0000000..aa008b7
--- /dev/null
+++ b/src/agents/commandcode.md
@@ -0,0 +1,56 @@
+---
+name: csp-search
+description: Code search agent for exploring any codebase. Use for finding code by intent, locating implementations, understanding how something works, or discovering related code. Prefer over bash/read_file for any semantic or exploratory question.
+tools: bash, read_file
+---
+
+Use `csp search` to find code by describing what it does or naming a symbol/identifier, instead of grep:
+
+```bash
+csp search "authentication flow" ./my-project
+csp search "save_pretrained" ./my-project
+csp search "save model to disk" ./my-project --top-k 10
+```
+
+If you anticipate doing more than one search, use `csp index` to create an index.
+
+```bash
+csp index ./my-project -o my_index
+```
+
+You can then reuse this index later on:
+
+```bash
+csp search "save_pretrained" --index my_index
+```
+
+An index is not automatically updated, so if the code changes significantly, reindex. If you notice stale results while resolving searches to files, reindex.
+
+Use `--content docs` to search documentation and prose, `--content config` for config files (yaml, toml, etc.), or `--content all` to search code, docs, and config:
+
+```bash
+csp search "deployment guide" ./my-project --content docs
+csp search "database host port" ./my-project --content config
+csp search "authentication" ./my-project --content all
+```
+
+Use `csp find-related` to discover code similar to a known location (pass `filePath` and `line` from a prior search result):
+
+```bash
+csp find-related src/auth.ts 42 ./my-project
+```
+
+Like search, `find-related` also accepts an `--index` argument.
+
+`path` defaults to the current directory when omitted; git URLs are accepted.
+
+If `csp` is not on `$PATH`, use `bunx @pleaseai/csp` in its place.
+
+### Workflow
+
+1. Index the repo using `csp index -o cached_index`.
+2. Start with `csp search` to find relevant chunks. Pass the index to achieve results faster.
+3. Use `--content docs` for documentation, `--content config` for config files, or `--content all` for everything.
+4. Inspect full files only when the returned chunk does not give enough context.
+5. Optionally use `csp find-related` with a promising result's `filePath` and `line` to discover related implementations.
+6. Use grep only when you need exhaustive literal matches or quick confirmation of an exact string.
\ No newline at end of file
diff --git a/src/agents/pi.md b/src/agents/pi.md
new file mode 100644
index 0000000..374f998
--- /dev/null
+++ b/src/agents/pi.md
@@ -0,0 +1,55 @@
+---
+name: csp-search
+description: Code search agent for exploring any codebase. Use for finding code by intent, locating implementations, understanding how something works, or discovering related code. Prefer over Bash/Read for any semantic or exploratory question.
+---
+
+Use `csp search` to find code by describing what it does or naming a symbol/identifier, instead of grep:
+
+```bash
+csp search "authentication flow" ./my-project
+csp search "save_pretrained" ./my-project
+csp search "save model to disk" ./my-project --top-k 10
+```
+
+If you anticipate doing more than one search, use `csp index` to create an index.
+
+```bash
+csp index ./my-project -o my_index
+```
+
+You can then reuse this index later on:
+
+```bash
+csp search "save_pretrained" --index my_index
+```
+
+An index is not automatically updated, so if the code changes significantly, reindex. If you notice stale results while resolving searches to files, reindex.
+
+Use `--content docs` to search documentation and prose, `--content config` for config files (yaml, toml, etc.), or `--content all` to search code, docs, and config:
+
+```bash
+csp search "deployment guide" ./my-project --content docs
+csp search "database host port" ./my-project --content config
+csp search "authentication" ./my-project --content all
+```
+
+Use `csp find-related` to discover code similar to a known location (pass `filePath` and `line` from a prior search result):
+
+```bash
+csp find-related src/auth.ts 42 ./my-project
+```
+
+Like search, `find-related` also accepts an `--index` argument.
+
+`path` defaults to the current directory when omitted; git URLs are accepted.
+
+If `csp` is not on `$PATH`, use `bunx @pleaseai/csp` in its place.
+
+### Workflow
+
+1. Index the repo using `csp index -o cached_index`.
+2. Start with `csp search` to find relevant chunks. Pass the index to achieve results faster.
+3. Use `--content docs` for documentation, `--content config` for config files, or `--content all` for everything.
+4. Inspect full files only when the returned chunk does not give enough context.
+5. Optionally use `csp find-related` with a promising result's `filePath` and `line` to discover related implementations.
+6. Use grep only when you need exhaustive literal matches or quick confirmation of an exact string.
\ No newline at end of file
diff --git a/src/agents/reasonix.md b/src/agents/reasonix.md
new file mode 100644
index 0000000..9353344
--- /dev/null
+++ b/src/agents/reasonix.md
@@ -0,0 +1,57 @@
+---
+name: csp-search
+description: Code search agent for exploring any codebase. Use for finding code by intent, locating implementations, understanding how something works, or discovering related code. Prefer over bash/read_file for any semantic or exploratory question.
+runAs: subagent
+allowed-tools: bash, read_file
+---
+
+Use `csp search` to find code by describing what it does or naming a symbol/identifier, instead of grep:
+
+```bash
+csp search "authentication flow" ./my-project
+csp search "save_pretrained" ./my-project
+csp search "save model to disk" ./my-project --top-k 10
+```
+
+If you anticipate doing more than one search, use `csp index` to create an index.
+
+```bash
+csp index ./my-project -o my_index
+```
+
+You can then reuse this index later on:
+
+```bash
+csp search "save_pretrained" --index my_index
+```
+
+An index is not automatically updated, so if the code changes significantly, reindex. If you notice stale results while resolving searches to files, reindex.
+
+Use `--content docs` to search documentation and prose, `--content config` for config files (yaml, toml, etc.), or `--content all` to search code, docs, and config:
+
+```bash
+csp search "deployment guide" ./my-project --content docs
+csp search "database host port" ./my-project --content config
+csp search "authentication" ./my-project --content all
+```
+
+Use `csp find-related` to discover code similar to a known location (pass `filePath` and `line` from a prior search result):
+
+```bash
+csp find-related src/auth.ts 42 ./my-project
+```
+
+Like search, `find-related` also accepts an `--index` argument.
+
+`path` defaults to the current directory when omitted; git URLs are accepted.
+
+If `csp` is not on `$PATH`, use `bunx @pleaseai/csp` in its place.
+
+### Workflow
+
+1. Index the repo using `csp index -o cached_index`.
+2. Start with `csp search` to find relevant chunks. Pass the index to achieve results faster.
+3. Use `--content docs` for documentation, `--content config` for config files, or `--content all` for everything.
+4. Inspect full files only when the returned chunk does not give enough context.
+5. Optionally use `csp find-related` with a promising result's `filePath` and `line` to discover related implementations.
+6. Use grep only when you need exhaustive literal matches or quick confirmation of an exact string.
\ No newline at end of file
diff --git a/src/cli.test.ts b/src/cli.test.ts
index 3ae60dc..5ff6c56 100644
--- a/src/cli.test.ts
+++ b/src/cli.test.ts
@@ -12,12 +12,16 @@ import { ContentType, type SearchResult } from './types.ts'
describe('Agent enum', () => {
test('enum values', () => {
+ expect(String(Agent.Antigravity)).toBe('antigravity')
expect(String(Agent.Claude)).toBe('claude')
+ expect(String(Agent.Commandcode)).toBe('commandcode')
expect(String(Agent.Copilot)).toBe('copilot')
expect(String(Agent.Cursor)).toBe('cursor')
expect(String(Agent.Gemini)).toBe('gemini')
expect(String(Agent.Kiro)).toBe('kiro')
expect(String(Agent.Opencode)).toBe('opencode')
+ expect(String(Agent.Pi)).toBe('pi')
+ expect(String(Agent.Reasonix)).toBe('reasonix')
})
})
@@ -34,6 +38,12 @@ describe('_agentPath', () => {
test('opencode → .opencode/agents/csp-search.md', () => {
expect(_agentPath(Agent.Opencode)).toBe('.opencode/agents/csp-search.md')
})
+ test('antigravity → .antigravity/agents/csp-search.md', () => {
+ expect(_agentPath(Agent.Antigravity)).toBe('.antigravity/agents/csp-search.md')
+ })
+ test('reasonix → .reasonix/agents/csp-search.md', () => {
+ expect(_agentPath(Agent.Reasonix)).toBe('.reasonix/agents/csp-search.md')
+ })
})
describe('parseArgs', () => {
@@ -281,6 +291,91 @@ describe('csp savings', () => {
})
})
+describe('csp clear', () => {
+ function captureStdout(): { writes: string[], restore: () => void } {
+ const writes: string[] = []
+ const origWrite = process.stdout.write.bind(process.stdout)
+ process.stdout.write = ((chunk: string | Uint8Array) => {
+ writes.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
+ return true
+ }) as typeof process.stdout.write
+ return { writes, restore: () => { process.stdout.write = origWrite } }
+ }
+
+ test('clear savings deletes the file and reports the path', async () => {
+ const { writes, restore } = captureStdout()
+ let called = 0
+ try {
+ const code = await runCli(['clear', 'savings'], {
+ clearSavings: () => { called++; return { path: '/tmp/x/savings.jsonl', cleared: true } },
+ })
+ expect(code).toBe(0)
+ }
+ finally {
+ restore()
+ }
+ expect(called).toBe(1)
+ expect(writes.join('')).toContain('Cleared savings at `/tmp/x/savings.jsonl`')
+ })
+
+ test('clear savings reports when no file exists', async () => {
+ const { writes, restore } = captureStdout()
+ try {
+ await runCli(['clear', 'savings'], {
+ clearSavings: () => ({ path: '/tmp/x/savings.jsonl', cleared: false }),
+ })
+ }
+ finally {
+ restore()
+ }
+ expect(writes.join('')).toContain('No savings file found at `/tmp/x/savings.jsonl`')
+ })
+
+ test('clear index notes there is no managed index cache', async () => {
+ const { writes, restore } = captureStdout()
+ let called = 0
+ try {
+ await runCli(['clear', 'index'], {
+ clearSavings: () => { called++; return { path: '/tmp/x/savings.jsonl', cleared: true } },
+ })
+ }
+ finally {
+ restore()
+ }
+ expect(called).toBe(0) // index-only must not touch savings
+ expect(writes.join('')).toContain('No index cache to clear')
+ })
+
+ test('clear all clears savings and notes the index', async () => {
+ const { writes, restore } = captureStdout()
+ try {
+ await runCli(['clear', 'all'], {
+ clearSavings: () => ({ path: '/tmp/x/savings.jsonl', cleared: true }),
+ })
+ }
+ finally {
+ restore()
+ }
+ const out = writes.join('')
+ expect(out).toContain('No index cache to clear')
+ expect(out).toContain('Cleared savings at')
+ })
+
+ test('clear with an invalid type exits 1', async () => {
+ const code = await runCli(['clear', 'bogus'], {
+ clearSavings: () => ({ path: '/tmp/x/savings.jsonl', cleared: true }),
+ })
+ expect(code).toBe(1)
+ })
+
+ test('clear with no type exits 1', async () => {
+ const code = await runCli(['clear'], {
+ clearSavings: () => ({ path: '/tmp/x/savings.jsonl', cleared: true }),
+ })
+ expect(code).toBe(1)
+ })
+})
+
describe('csp mcp', () => {
test('dispatches to serve with path and content', async () => {
let captured: { path?: string | undefined, ref?: string | undefined, content?: ContentType[] } = {}
@@ -334,6 +429,13 @@ describe('_readAgentFile', () => {
expect(text.length).toBeGreaterThan(0)
expect(text).toContain('csp')
})
+ test('a bundled template exists for every agent', async () => {
+ for (const agent of Object.values(Agent)) {
+ const text = await _readAgentFile(agent)
+ expect(text).toContain('name: csp-search')
+ expect(text).toContain('csp search')
+ }
+ })
})
describe('runCli error handling', () => {
diff --git a/src/cli.ts b/src/cli.ts
index 5f45522..81ddca5 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -8,17 +8,21 @@ import { fileURLToPath } from 'node:url'
// TODO(integration): replace stub when sibling modules land
import { CspIndex } from './indexing/index.ts'
import { serve } from './mcp/server.ts'
-import { formatSavingsReport } from './stats.ts'
+import { clearSavings, formatSavingsReport } from './stats.ts'
import { ContentType } from './types.ts'
import { formatResults, isGitUrl, resolveChunk } from './utils.ts'
export enum Agent {
+ Antigravity = 'antigravity',
Claude = 'claude',
+ Commandcode = 'commandcode',
Copilot = 'copilot',
Cursor = 'cursor',
Gemini = 'gemini',
Kiro = 'kiro',
Opencode = 'opencode',
+ Pi = 'pi',
+ Reasonix = 'reasonix',
}
const DEFAULT_AGENT = Agent.Claude
@@ -27,12 +31,15 @@ const CLI_DISPATCH_ARGS = new Set([
'find-related',
'init',
'savings',
+ 'clear',
'index',
'mcp',
'-h',
'--help',
])
+const CLEAR_CHOICES = ['all', 'index', 'savings'] as const
+
const CONTENT_CHOICES = ['code', 'docs', 'config', 'all'] as const
export function _agentPath(agent: Agent): string {
@@ -189,13 +196,14 @@ Commands:
find-related [path] Find code similar to a specific location.
init Write a csp sub-agent file for your coding agent.
savings Show token savings and usage stats.
+ clear Clear cached data (savings telemetry).
mcp [path] Start the MCP server (optionally pre-index path).
Common options:
--top-k , -k Number of results (default: 5).
--content Content types: code, docs, config, all (default: code).
--index Path to a pre-built index.
- --agent , -a One of: claude, copilot, cursor, gemini, kiro, opencode.
+ --agent , -a One of: antigravity, claude, commandcode, copilot, cursor, gemini, kiro, opencode, pi, reasonix.
--force Overwrite if file already exists (init).
-o, --out Write the pre-built index to this path (index).
--ref [ Branch or tag for git URLs (mcp).
@@ -221,6 +229,7 @@ interface RunOptions {
writeFileImpl?: (path: string, content: string) => Promise
readAgentFile?: (agent: Agent) => Promise
formatSavings?: (opts: { verbose: boolean }) => string
+ clearSavings?: () => { path: string, cleared: boolean }
cwd?: () => string
}
@@ -279,6 +288,44 @@ async function _runIndex(opts: {
await index.save(out)
}
+/**
+ * Run the `clear` subcommand.
+ *
+ * `clear savings` (and `all`) deletes the `~/.csp/savings.jsonl` telemetry
+ * file. `clear index` is currently a no-op note: index persistence is not
+ * wired up yet (the `CspIndex` orchestrator is a stub), and the storage model
+ * — repo-local `.csp/` vs a global cache — is still undecided. For now
+ * `csp index -o ` writes only to the path you pass, so delete those
+ * directories yourself.
+ */
+export function _runClear(
+ type: string,
+ clearSavingsImpl: () => { path: string, cleared: boolean } = clearSavings,
+): number {
+ if (!(CLEAR_CHOICES as readonly string[]).includes(type)) {
+ process.stderr.write(`Invalid clear type: ${type}. Choices: ${CLEAR_CHOICES.join(', ')}\n`)
+ return 1
+ }
+
+ if (type === 'index' || type === 'all') {
+ process.stdout.write(
+ 'No index cache to clear — index persistence is not wired up yet; '
+ + '`csp index -o ` writes only to the path you choose.\n',
+ )
+ }
+
+ if (type === 'savings' || type === 'all') {
+ const { path: statsPath, cleared } = clearSavingsImpl()
+ process.stdout.write(
+ cleared
+ ? `Cleared savings at \`${statsPath}\`\n`
+ : `No savings file found at \`${statsPath}\`\n`,
+ )
+ }
+
+ return 0
+}
+
export async function runCli(argv: string[], options: RunOptions = {}): Promise {
// Bare invocation prints help and exits 0; unknown subcommands are handled
// below (after parsing) so they exit 1.
@@ -340,6 +387,17 @@ export async function runCli(argv: string[], options: RunOptions = {}): Promise<
return 0
}
+ if (command === 'clear') {
+ const type = positional[0]
+ if (type === undefined) {
+ process.stderr.write(`clear requires a type. Choices: ${CLEAR_CHOICES.join(', ')}\n`)
+ return 1
+ }
+ return options.clearSavings
+ ? _runClear(type, options.clearSavings)
+ : _runClear(type)
+ }
+
if (command === 'mcp') {
const path = positional[0]
const ref = _getStringFlag(flags, 'ref')
@@ -421,7 +479,18 @@ export async function runCli(argv: string[], options: RunOptions = {}): Promise<
}
function _coerceAgent(raw: string): Agent {
- const candidates: Agent[] = [Agent.Claude, Agent.Copilot, Agent.Cursor, Agent.Gemini, Agent.Kiro, Agent.Opencode]
+ const candidates: Agent[] = [
+ Agent.Antigravity,
+ Agent.Claude,
+ Agent.Commandcode,
+ Agent.Copilot,
+ Agent.Cursor,
+ Agent.Gemini,
+ Agent.Kiro,
+ Agent.Opencode,
+ Agent.Pi,
+ Agent.Reasonix,
+ ]
for (const a of candidates) {
if (a === raw) return a
}
diff --git a/src/stats.test.ts b/src/stats.test.ts
index cb08457..886a5f7 100644
--- a/src/stats.test.ts
+++ b/src/stats.test.ts
@@ -6,6 +6,7 @@ import path from 'node:path'
import {
BucketStats,
buildSavingsSummary,
+ clearSavings,
formatSavingsReport,
resetStatsFile,
saveSearchStats,
@@ -209,6 +210,21 @@ describe('buildSavingsSummary', () => {
})
})
+describe('clearSavings', () => {
+ test('deletes an existing stats file and reports cleared=true', () => {
+ appendFileSync(statsFile, `${JSON.stringify({ ts: 1, call: 'search', results: 1, snippet_chars: 1, file_chars: 2 })}\n`)
+ expect(existsSync(statsFile)).toBe(true)
+ const result = clearSavings()
+ expect(result).toEqual({ path: statsFile, cleared: true })
+ expect(existsSync(statsFile)).toBe(false)
+ })
+
+ test('reports cleared=false when no stats file exists', () => {
+ expect(existsSync(statsFile)).toBe(false)
+ expect(clearSavings()).toEqual({ path: statsFile, cleared: false })
+ })
+})
+
describe('formatSavingsReport', () => {
test('shows "Csp Token Savings" header and bucket labels', () => {
const now = Date.now() / 1000
@@ -242,8 +258,13 @@ describe('formatSavingsReport', () => {
expect(report).toContain('~1.5k')
})
- test('verbose appends Usage Breakdown section with sorted call types', () => {
+ test('verbose appends By Call Type section sorted by call count descending', () => {
const now = Date.now() / 1000
+ // search appears 2×, find_related 1× ⇒ search ranks first by count.
+ appendFileSync(
+ statsFile,
+ `${JSON.stringify({ ts: now, call: 'search', results: 1, snippet_chars: 100, file_chars: 400 })}\n`,
+ )
appendFileSync(
statsFile,
`${JSON.stringify({ ts: now, call: 'search', results: 1, snippet_chars: 100, file_chars: 400 })}\n`,
@@ -254,26 +275,35 @@ describe('formatSavingsReport', () => {
)
const report = formatSavingsReport({ verbose: true })
- expect(report).toContain('Usage Breakdown')
+ expect(report).toContain('By Call Type')
expect(report).toContain('Call type')
expect(report).toContain('search')
expect(report).toContain('find_related')
- // Sorted alphabetically — find_related should appear before search.
- const findIdx = report.indexOf('find_related')
- const searchHeadingsStripped = report.replace('Csp Token Savings', '')
- const searchIdx = searchHeadingsStripped.indexOf('search')
- expect(findIdx).toBeLessThan(searchIdx + 'Csp Token Savings'.length)
+ // Sorted by count descending — the more frequent `search` precedes `find_related`.
+ const searchIdx = report.indexOf('By Call Type')
+ const breakdown = report.slice(searchIdx)
+ expect(breakdown.indexOf('search')).toBeLessThan(breakdown.indexOf('find_related'))
+ })
+
+ test('By Call Type section is hidden without --verbose', () => {
+ const now = Date.now() / 1000
+ appendFileSync(
+ statsFile,
+ `${JSON.stringify({ ts: now, call: 'search', results: 1, snippet_chars: 100, file_chars: 400 })}\n`,
+ )
+ expect(formatSavingsReport()).not.toContain('By Call Type')
})
- test('renders bar with filled blocks proportional to ratio', () => {
+ test('renders By Period bar with filled blocks proportional to ratio', () => {
const now = Date.now() / 1000
- // ratio = 0.75 ⇒ 12 filled / 4 empty out of 16.
+ // ratio = 0.75 over a 24-wide bar ⇒ 18 filled / 6 empty.
appendFileSync(
statsFile,
`${JSON.stringify({ ts: now, call: 'search', results: 1, snippet_chars: 100, file_chars: 400 })}\n`,
)
const report = formatSavingsReport()
- expect(report).toContain('[████████████░░░░]')
+ expect(report).toContain(`${'█'.repeat(18)}${'░'.repeat(6)}`)
+ // Overall ratio is surfaced in the headline summary as "(75%)".
expect(report).toContain('(75%)')
})
diff --git a/src/stats.ts b/src/stats.ts
index 02ea0c9..2970732 100644
--- a/src/stats.ts
+++ b/src/stats.ts
@@ -1,7 +1,8 @@
// Port of src/semble/stats.py
-import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
+import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
import { homedir } from 'node:os'
import path from 'node:path'
+import process from 'node:process'
/**
* Call type for token-savings tracking.
@@ -114,6 +115,27 @@ export function saveSearchStats(
}
}
+/**
+ * Delete the savings stats file if it exists.
+ *
+ * Deletion (not truncation) mirrors semble's `clear` (`path.unlink()`) and
+ * lets `csp savings` fall back to the "No stats yet" message — a truncated,
+ * still-present file would instead render an all-zero report. Best-effort:
+ * a permission error or broken symlink is swallowed and reported as
+ * `cleared: false` rather than crashing the CLI.
+ */
+export function clearSavings(): { path: string, cleared: boolean } {
+ if (!existsSync(_STATS_FILE))
+ return { path: _STATS_FILE, cleared: false }
+ try {
+ rmSync(_STATS_FILE)
+ return { path: _STATS_FILE, cleared: true }
+ }
+ catch {
+ return { path: _STATS_FILE, cleared: false }
+ }
+}
+
interface StatsRecord {
ts: number
call: string
@@ -215,6 +237,33 @@ function padRight(s: string, width: number): string {
return s + ' '.repeat(width - s.length)
}
+function padLeft(s: string, width: number): string {
+ if (s.length >= width)
+ return s
+ return ' '.repeat(width - s.length) + s
+}
+
+/**
+ * Whether ANSI colors should be emitted. Mirrors semble's `_use_color`:
+ * suppressed under `NO_COLOR`, a `dumb` terminal, or a non-TTY stdout.
+ */
+function useColor(): boolean {
+ return !('NO_COLOR' in process.env)
+ && process.env.TERM !== 'dumb'
+ && Boolean(process.stdout.isTTY)
+}
+
+/** Wrap `text` in an ANSI color `code` when `enabled`. */
+function color(code: string, text: string, enabled: boolean): string {
+ return enabled ? `[${code}m${text}[0m` : text
+}
+
+/** Color a savings percentage by value: green ≥80, yellow ≥50, red below. */
+function colorRatio(pct: number, enabled: boolean): string {
+ const code = pct >= 80 ? '32' : pct >= 50 ? '33' : '31'
+ return color(code, `${pct}%`, enabled)
+}
+
function formatSavedTokens(savedTokens: number): string {
if (savedTokens >= 1_000_000)
return `~${(savedTokens / 1_000_000).toFixed(1)}M`
@@ -235,8 +284,12 @@ export interface FormatSavingsReportOptions {
/**
* Return a formatted token-savings report.
*
- * Output mirrors semble's ASCII bar chart byte-for-byte, with the header
- * swapped from "Semble Token Savings" → "Csp Token Savings".
+ * Adopts semble's redesigned layout (PR #197): a headline summary
+ * (Total saved / Total calls / Efficiency bar) followed by a "By Period"
+ * table, with ANSI color when stdout is a color-capable TTY. Two csp
+ * divergences are preserved: the header reads "Csp Token Savings" (not
+ * "Semble Token Savings"), and the "By Call Type" breakdown stays gated
+ * behind `--verbose` rather than always shown.
*/
export function formatSavingsReport(options: FormatSavingsReportOptions = {}): string {
const target = options.path ?? _STATS_FILE
@@ -246,44 +299,84 @@ export function formatSavingsReport(options: FormatSavingsReportOptions = {}): s
return 'No stats yet. Run a search first.'
const summary = buildSavingsSummary(target)
- const barWidth = 16
- const heavyLine = ` ${'═'.repeat(64)}`
- const lightLine = ` ${'─'.repeat(64)}`
+ const enabled = useColor()
+ const barWidth = 24
+ const borderWidth = 72
+ const heavyLine = ` ${color('38;5;244', '═'.repeat(borderWidth), enabled)}`
+ const lightLine = ` ${color('38;5;244', '─'.repeat(borderWidth), enabled)}`
+
+ const allTime = summary.buckets['All time']!
+ const totalSavedTokens = Math.floor(allTime.savedChars / 4) // ~4 chars/token approximation
+ const overallPct = allTime.fileChars > 0
+ ? Math.round((allTime.savedChars / allTime.fileChars) * 100)
+ : 0
+ const efficiencyFilled = Math.round((overallPct / 100) * barWidth)
+ const efficiencyBar
+ = color('32', '█'.repeat(efficiencyFilled), enabled)
+ + color('38;5;244', '░'.repeat(barWidth - efficiencyFilled), enabled)
const lines: string[] = [
'',
- ' Csp Token Savings',
+ ` ${color('1;36', 'Csp Token Savings', enabled)}`,
heavyLine,
- ` ${padRight('Period', 12)} ${padRight('Calls', 6)} Savings`,
+ '',
+ ` ${color('1', 'Total saved:', enabled)} ${color('1;33', `${formatSavedTokens(totalSavedTokens)} tokens`, enabled)} (${colorRatio(overallPct, enabled)})`,
+ ` ${color('1', 'Total calls:', enabled)} ${color('1;33', formatCalls(allTime.calls), enabled)}`,
+ ` ${color('1', 'Efficiency:', enabled)} ${efficiencyBar} ${colorRatio(overallPct, enabled)}`,
+ '',
+ ` ${color('1', 'By Period', enabled)}`,
+ lightLine,
+ ` ${padRight('Period', 14)} ${padLeft('Calls', 8)} ${padLeft('Saved', 14)} Ratio`,
lightLine,
]
for (const [label, bucket] of Object.entries(summary.buckets)) {
- const savedTokens = Math.floor(bucket.savedChars / 4) // ~4 chars/token approximation
- const savedStr = formatSavedTokens(savedTokens)
+ const savedTokens = Math.floor(bucket.savedChars / 4)
+ const savedStr = `${formatSavedTokens(savedTokens)} tokens`
const callsStr = formatCalls(bucket.calls)
+ let rowBar: string
+ let ratioStr: string
if (bucket.fileChars > 0) {
const ratio = bucket.savedChars / bucket.fileChars
const filled = Math.round(ratio * barWidth)
- const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled)
- const pct = Math.round(ratio * 100)
- lines.push(` ${padRight(label, 12)} ${padRight(callsStr, 6)} [${bar}] ${savedStr} tokens (${pct}%)`)
+ rowBar = color('32', '█'.repeat(filled), enabled) + color('38;5;244', '░'.repeat(barWidth - filled), enabled)
+ ratioStr = colorRatio(Math.round(ratio * 100), enabled)
}
else {
- lines.push(` ${padRight(label, 12)} ${padRight(callsStr, 6)} [${'░'.repeat(barWidth)}] ${savedStr} tokens`)
+ rowBar = color('38;5;244', '░'.repeat(barWidth), enabled)
+ ratioStr = color('38;5;244', '–', enabled)
}
+ lines.push(
+ ` ${color('1', padRight(label, 14), enabled)} ${color('1;33', padLeft(callsStr, 8), enabled)} `
+ + `${color('1;33', padLeft(savedStr, 14), enabled)} ${rowBar} ${ratioStr}`,
+ )
}
const callTypeEntries = Object.entries(summary.callTypeCounts)
if (verbose && callTypeEntries.length > 0) {
- lines.push('', ' Usage Breakdown', lightLine, ` ${padRight('Call type', 16)} Calls`)
- const sorted = callTypeEntries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
- for (const [callType, count] of sorted) {
- const countStr = count >= 1000 ? `${(count / 1000).toFixed(1)}k` : String(count)
- lines.push(` ${padRight(callType, 16)} ${countStr}`)
- }
- lines.push(heavyLine)
+ lines.push(
+ '',
+ ` ${color('1', 'By Call Type', enabled)}`,
+ lightLine,
+ ` ${padRight('#', 4)} ${padRight('Call type', 16)} ${padLeft('Calls', 8)} Share`,
+ lightLine,
+ )
+ const total = callTypeEntries.reduce((sum, [, count]) => sum + count, 0)
+ // Sort by call count descending; ties keep insertion order.
+ const sorted = [...callTypeEntries].sort(([, a], [, b]) => b - a)
+ sorted.forEach(([callType, count], i) => {
+ const share = total > 0 ? count / total : 0
+ const filled = Math.max(1, Math.round(share * 16))
+ const bar = color('32', '█'.repeat(filled), enabled) + color('38;5;244', '░'.repeat(16 - filled), enabled)
+ const rank = `${i + 1}.`
+ lines.push(
+ ` ${color('38;5;244', padRight(rank, 4), enabled)} ${padRight(callType, 16)} `
+ + `${color('1;33', padLeft(formatCalls(count), 8), enabled)} ${bar} `
+ + `${color('38;5;244', padLeft(`${Math.round(share * 100)}%`, 4), enabled)}`,
+ )
+ })
}
+ lines.push(heavyLine)
lines.push('')
return lines.join('\n')
}
]