@blogic-cz/agent-tools 0.5.7 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,6 +27,25 @@ These tools wrap each CLI with:
27
27
  bun add -d @blogic-cz/agent-tools
28
28
  ```
29
29
 
30
+ ### Skill installation
31
+
32
+ Install per-tool skills for only the tools your project uses:
33
+
34
+ ```bash
35
+ npx skills add blogic-cz/agent-tools --path skill/gh-tool
36
+ npx skills add blogic-cz/agent-tools --path skill/db-tool
37
+ npx skills add blogic-cz/agent-tools --path skill/k8s-tool
38
+ npx skills add blogic-cz/agent-tools --path skill/az-tool
39
+ npx skills add blogic-cz/agent-tools --path skill/logs-tool
40
+ npx skills add blogic-cz/agent-tools --path skill/session-tool
41
+ ```
42
+
43
+ Or install the all-in-one skill that covers every tool:
44
+
45
+ ```bash
46
+ npx skills add blogic-cz/agent-tools --path skill/agent-tools
47
+ ```
48
+
30
49
  **Requirements:** [Bun](https://bun.sh/) >=1.0.0
31
50
 
32
51
  ### For agents reading this
@@ -41,8 +60,8 @@ Follow these steps to set up agent-tools in the consumer project.
41
60
  ```
42
61
  - **Non-JavaScript project** (no `package.json`): Skip installation — run tools directly with `bunx`:
43
62
  ```bash
44
- bunx agent-tools-gh pr status
45
- bunx agent-tools-k8s pods --env test
63
+ bunx gh-tool pr status
64
+ bunx k8s-tool pods --env test
46
65
  ```
47
66
 
48
67
  **Step 2 — Configure**
@@ -68,32 +87,54 @@ Set up the credential guard for the agent's platform:
68
87
 
69
88
  For each tool the user selected in Step 2:
70
89
 
71
- 1. Run `bunx agent-tools-<tool> --help` to verify it works
90
+ 1. Run `bun <tool-name> --help` to verify it works (e.g. `bun gh-tool --help`)
72
91
  2. Summarize the key commands available
73
92
  3. Show the user what data they have access to based on their `agent-tools.json5` — e.g. which environments, clusters, namespaces, databases, or profiles are configured and reachable
74
93
 
75
94
  **Step 5 — Skill & Agent Docs**
76
95
 
77
- Tell the user to run the following command themselves it's interactive and lets them choose which agents to install the skill for:
96
+ Install **only the per-tool skills the project needs** (recommended), or the all-in-one skill:
78
97
 
79
98
  ```bash
80
- npx skills add blogic-cz/agent-tools
99
+ # Per-tool skills (recommended) — install only what the project uses
100
+ npx skills add blogic-cz/agent-tools --path skill/gh-tool
101
+ npx skills add blogic-cz/agent-tools --path skill/db-tool
102
+ npx skills add blogic-cz/agent-tools --path skill/k8s-tool
103
+ npx skills add blogic-cz/agent-tools --path skill/az-tool
104
+ npx skills add blogic-cz/agent-tools --path skill/logs-tool
105
+ npx skills add blogic-cz/agent-tools --path skill/session-tool
106
+
107
+ # All-in-one skill (alternative) — all tools in a single skill
108
+ npx skills add blogic-cz/agent-tools --path skill/agent-tools
81
109
  ```
82
110
 
83
- **Do not run this command for the user** — it requires interactive selection.
111
+ **Do not run these commands for the user** — they require interactive selection.
112
+
113
+ Available per-tool skills:
114
+
115
+ | Skill | Install when project uses |
116
+ | -------------- | ------------------------------------------ |
117
+ | `gh-tool` | GitHub PRs, issues, workflows, CI checks |
118
+ | `db-tool` | SQL queries, schema introspection |
119
+ | `k8s-tool` | Kubernetes pods, logs, deployments |
120
+ | `az-tool` | Azure DevOps pipelines, builds |
121
+ | `logs-tool` | Application log reading (local and remote) |
122
+ | `session-tool` | OpenCode session history browsing |
123
+ | `agent-tools` | All of the above in a single skill |
84
124
 
85
125
  Then update the project's `AGENTS.md` and/or `CLAUDE.md`:
86
126
 
87
- 1. Add an `agent-tools` row to the skills table (if one exists):
127
+ 1. Add rows to the skills table for each installed skill (if one exists):
88
128
  ```markdown
89
- | Agent wrapper tools (`db-tool`, `k8s-tool`, `logs-tool`, `az-tool`, `gh` patterns) | `agent-tools` |
129
+ | GitHub PRs, issues, workflows | `gh-tool` |
130
+ | Database queries, schema | `db-tool` |
90
131
  ```
91
132
  2. Add or update the **Tooling** section:
92
133
 
93
134
  ```markdown
94
135
  ## Tooling
95
136
 
96
- For tool wrappers and operational patterns, load `agent-tools`.
137
+ For tool wrappers and operational patterns, load the relevant tool skill (`gh-tool`, `db-tool`, etc.).
97
138
  ```
98
139
 
99
140
  **Step 6 — Custom Tool Scaffold**
@@ -146,34 +187,17 @@ bun run agent-tools/example-tool/index.ts ping
146
187
  3. Run tools:
147
188
 
148
189
  ```bash
149
- bunx agent-tools-gh pr status
150
- bunx agent-tools-k8s kubectl --env test --cmd "get pods"
151
- bunx agent-tools-logs list --env local
152
- bunx agent-tools-audit list --limit 20
190
+ bun gh-tool pr status
191
+ bun k8s-tool kubectl --env test --cmd "get pods"
192
+ bun logs-tool list --env local
193
+ bun audit-tool list --limit 20
153
194
  ```
154
195
 
155
196
  ```bash
156
- bunx agent-tools-gh pr review-triage # interactive summary of PR feedback
157
- bunx agent-tools-k8s pods --env test # list pods (structured command)
197
+ bun gh-tool pr review-triage # interactive summary of PR feedback
198
+ bun k8s-tool pods --env test # list pods (structured command)
158
199
  ```
159
200
 
160
- Optionally, add script aliases to your `package.json` for shorter invocation:
161
-
162
- ```json
163
- {
164
- "scripts": {
165
- "gh-tool": "agent-tools-gh",
166
- "audit-tool": "agent-tools-audit",
167
- "k8s-tool": "agent-tools-k8s",
168
- "db-tool": "agent-tools-db",
169
- "logs-tool": "agent-tools-logs",
170
- "session-tool": "agent-tools-session"
171
- }
172
- }
173
- ```
174
-
175
- Then run via `bun run k8s-tool -- pods --env test` instead of `bunx agent-tools-k8s pods --env test`.
176
-
177
201
  4. Hook up the credential guard in your agent config (Claude Code, OpenCode, etc.):
178
202
 
179
203
  ```typescript
@@ -184,19 +208,19 @@ export default { handleToolExecuteBefore };
184
208
 
185
209
  ## Tools
186
210
 
187
- | Binary | Description |
188
- | --------------------- | ---------------------------------------------------------------------------------------------------------------- |
189
- | `agent-tools-gh` | GitHub CLI wrapper — PR management, issues, workflows, composite commands (`review-triage`, `reply-and-resolve`) |
190
- | `agent-tools-audit` | Audit trail browser — inspect recent tool invocations and purge old entries |
191
- | `agent-tools-db` | Database query tool — SQL execution, schema introspection |
192
- | `agent-tools-k8s` | Kubernetes tool — kubectl wrapper + structured commands (`pods`, `logs`, `describe`, `exec`, `top`) |
193
- | `agent-tools-az` | Azure DevOps tool — pipelines, builds, repos |
194
- | `agent-tools-logs` | Application logs — read local and remote (k8s pod) logs |
195
- | `agent-tools-session` | OpenCode session browser — list, read, search sessions |
211
+ | Binary | Description |
212
+ | -------------- | ---------------------------------------------------------------------------------------------------------------- |
213
+ | `gh-tool` | GitHub CLI wrapper — PR management, issues, workflows, composite commands (`review-triage`, `reply-and-resolve`) |
214
+ | `audit-tool` | Audit trail browser — inspect recent tool invocations and purge old entries |
215
+ | `db-tool` | Database query tool — SQL execution, schema introspection |
216
+ | `k8s-tool` | Kubernetes tool — kubectl wrapper + structured commands (`pods`, `logs`, `describe`, `exec`, `top`) |
217
+ | `az-tool` | Azure DevOps tool — pipelines, builds, repos |
218
+ | `logs-tool` | Application logs — read local and remote (k8s pod) logs |
219
+ | `session-tool` | OpenCode session browser — list, read, search sessions |
196
220
 
197
- All tools support `--help` for full usage documentation.
221
+ All tools support `--help` for full usage documentation. Legacy `agent-tools-*` binary names (e.g. `agent-tools-gh`) still work for backwards compatibility.
198
222
 
199
- `agent-tools-audit` reads the same SQLite file the wrappers write to. By default that file lives at `~/.agent-tools/audit.sqlite`, and you can override both path and retention per repo with the global `audit` config section.
223
+ `audit-tool` reads the same SQLite file the wrappers write to. By default that file lives at `~/.agent-tools/audit.sqlite`, and you can override both path and retention per repo with the global `audit` config section.
200
224
 
201
225
  ## Audit Logging
202
226
 
@@ -212,19 +236,19 @@ Entries older than `retentionDays` (default: 90) are automatically purged on eac
212
236
 
213
237
  ```bash
214
238
  # Recent 20 entries (default)
215
- bunx agent-tools-audit list
239
+ bun audit-tool list
216
240
 
217
241
  # Last 50 entries, JSON format
218
- bunx agent-tools-audit list --limit 50 --format json
242
+ bun audit-tool list --limit 50 --format json
219
243
 
220
244
  # Filter by tool
221
- bunx agent-tools-audit list --tool gh
245
+ bun audit-tool list --tool gh
222
246
 
223
247
  # Filter by project directory
224
- bunx agent-tools-audit list --project /Users/me/my-repo
248
+ bun audit-tool list --project /Users/me/my-repo
225
249
 
226
250
  # Purge entries older than 30 days
227
- bunx agent-tools-audit purge --days 30
251
+ bun audit-tool purge --days 30
228
252
  ```
229
253
 
230
254
  ### Audit Configuration
@@ -293,8 +317,8 @@ Each tool section supports multiple named profiles. Select with `--profile <name
293
317
  ```
294
318
 
295
319
  ```bash
296
- bunx agent-tools-az cmd --cmd "pipelines list" # uses "default" profile
297
- bunx agent-tools-az cmd --cmd "pipelines list" --profile legacy # uses "legacy" profile
320
+ bun az-tool cmd --cmd "pipelines list" # uses "default" profile
321
+ bun az-tool cmd --cmd "pipelines list" --profile legacy # uses "legacy" profile
298
322
  ```
299
323
 
300
324
  **Profile resolution:** `--profile` flag > auto-select (single profile) > `"default"` key > error.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.5.7",
3
+ "version": "0.7.0",
4
4
  "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, sessions, and audit",
5
5
  "keywords": [
6
6
  "agent",
@@ -25,7 +25,14 @@
25
25
  "agent-tools-gh": "./src/gh-tool/index.ts",
26
26
  "agent-tools-k8s": "./src/k8s-tool/index.ts",
27
27
  "agent-tools-logs": "./src/logs-tool/index.ts",
28
- "agent-tools-session": "./src/session-tool/index.ts"
28
+ "agent-tools-session": "./src/session-tool/index.ts",
29
+ "audit-tool": "./src/audit-tool/index.ts",
30
+ "az-tool": "./src/az-tool/index.ts",
31
+ "db-tool": "./src/db-tool/index.ts",
32
+ "gh-tool": "./src/gh-tool/index.ts",
33
+ "k8s-tool": "./src/k8s-tool/index.ts",
34
+ "logs-tool": "./src/logs-tool/index.ts",
35
+ "session-tool": "./src/session-tool/index.ts"
29
36
  },
30
37
  "files": [
31
38
  "src/",
@@ -47,6 +47,7 @@ import {
47
47
  import { repoInfoCommand, repoListCommand, repoSearchCodeCommand } from "./repo";
48
48
  import { GitHubService } from "./service";
49
49
  import {
50
+ workflowAnnotationsCommand,
50
51
  workflowCancelCommand,
51
52
  workflowJobLogsCommand,
52
53
  workflowJobsCommand,
@@ -104,7 +105,7 @@ const repoCommand = Command.make("repo", {}).pipe(
104
105
 
105
106
  const workflowCommand = Command.make("workflow", {}).pipe(
106
107
  Command.withDescription(
107
- "GitHub Actions workflow operations (list runs, view, jobs, logs, job-logs, rerun, cancel, watch)",
108
+ "GitHub Actions workflow operations (list runs, view, jobs, logs, job-logs, annotations, rerun, cancel, watch)",
108
109
  ),
109
110
  Command.withSubcommands([
110
111
  workflowListCommand,
@@ -112,6 +113,7 @@ const workflowCommand = Command.make("workflow", {}).pipe(
112
113
  workflowJobsCommand,
113
114
  workflowLogsCommand,
114
115
  workflowJobLogsCommand,
116
+ workflowAnnotationsCommand,
115
117
  workflowRerunCommand,
116
118
  workflowCancelCommand,
117
119
  workflowWatchCommand,
@@ -152,10 +154,11 @@ WORKFLOW FOR AI AGENTS:
152
154
  12. Use 'workflow view --run N' to inspect a specific run with jobs/steps
153
155
  13. Use 'workflow logs --run N' to get logs (failed jobs by default)
154
156
  14. Use 'workflow job-logs --run N --job "build-web-app"' to get clean parsed logs for a specific job
155
- 15. Use 'workflow watch --run N' to watch until completion
156
- 16. Use 'release status' to inspect latest release + repository context
157
- 17. Use 'release create --tag vX.Y.Z --generate-notes' to publish a release
158
- 18. Use 'release edit/view/list/delete' to maintain existing releases`,
157
+ 15. Use 'workflow annotations --run N' to list CI annotations (errors, warnings, notices)
158
+ 16. Use 'workflow watch --run N' to watch until completion
159
+ 17. Use 'release status' to inspect latest release + repository context
160
+ 18. Use 'release create --tag vX.Y.Z --generate-notes' to publish a release
161
+ 19. Use 'release edit/view/list/delete' to maintain existing releases`,
159
162
  ),
160
163
  Command.withSubcommands([prCommand, issueCommand, repoCommand, workflowCommand, releaseCommand]),
161
164
  );
@@ -96,3 +96,21 @@ export type PRStatusNone = {
96
96
  };
97
97
 
98
98
  export type PRStatusResult = PRStatusSingle | PRStatusMultiple | PRStatusNone;
99
+
100
+ export type CheckRunAnnotation = {
101
+ path: string;
102
+ start_line: number;
103
+ end_line: number;
104
+ start_column: number | null;
105
+ end_column: number | null;
106
+ annotation_level: "notice" | "warning" | "failure";
107
+ title: string | null;
108
+ message: string;
109
+ raw_details: string | null;
110
+ };
111
+
112
+ export type JobAnnotations = {
113
+ jobId: number;
114
+ jobName: string;
115
+ annotations: CheckRunAnnotation[];
116
+ };
@@ -4,6 +4,7 @@ import { Console, Effect, Option } from "effect";
4
4
  import { formatOption, logFormatted } from "#shared";
5
5
  import { GitHubCommandError, GitHubNotFoundError } from "./errors";
6
6
  import { GitHubService } from "./service";
7
+ import type { CheckRunAnnotation, JobAnnotations } from "./types";
7
8
 
8
9
  // ---------------------------------------------------------------------------
9
10
  // Types
@@ -235,6 +236,69 @@ const watchRun = Effect.fn("workflow.watchRun")(function* (runId: number, repo:
235
236
  };
236
237
  });
237
238
 
239
+ const fetchAnnotations = Effect.fn("workflow.fetchAnnotations")(function* (opts: {
240
+ runId: number;
241
+ job: string | null;
242
+ repo: string | null;
243
+ }) {
244
+ const gh = yield* GitHubService;
245
+
246
+ let owner: string;
247
+ let repoName: string;
248
+ if (opts.repo !== null) {
249
+ const parts = opts.repo.split("/");
250
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
251
+ return yield* new GitHubCommandError({
252
+ message: `Invalid --repo format: "${opts.repo}". Expected "owner/name" (e.g. "blogic-cz/agent-tools").`,
253
+ command: "workflow annotations",
254
+ exitCode: 1,
255
+ stderr: "",
256
+ });
257
+ }
258
+ owner = parts[0];
259
+ repoName = parts[1];
260
+ } else {
261
+ const info = yield* gh.getRepoInfo();
262
+ owner = info.owner;
263
+ repoName = info.name;
264
+ }
265
+
266
+ const jobs = yield* listJobs(opts.runId, opts.repo);
267
+
268
+ let targetJobs = jobs;
269
+ if (opts.job !== null) {
270
+ const jobId = yield* resolveJobId(opts.runId, opts.job, opts.repo);
271
+ targetJobs = jobs.filter((j) => j.databaseId === jobId);
272
+ }
273
+
274
+ const results: JobAnnotations[] = [];
275
+ for (const job of targetJobs) {
276
+ const annotations = yield* gh
277
+ .runGhJson<CheckRunAnnotation[]>([
278
+ "api",
279
+ `repos/${owner}/${repoName}/check-runs/${job.databaseId}/annotations`,
280
+ "--paginate",
281
+ ])
282
+ .pipe(
283
+ Effect.catchTag("GitHubCommandError", () => Effect.succeed([] as CheckRunAnnotation[])),
284
+ );
285
+
286
+ if (annotations.length > 0) {
287
+ results.push({
288
+ jobId: job.databaseId,
289
+ jobName: job.name,
290
+ annotations,
291
+ });
292
+ }
293
+ }
294
+
295
+ return {
296
+ runId: opts.runId,
297
+ totalAnnotations: results.reduce((sum, r) => sum + r.annotations.length, 0),
298
+ jobs: results,
299
+ };
300
+ });
301
+
238
302
  // ---------------------------------------------------------------------------
239
303
  // Log parsing utilities (pure functions)
240
304
  // ---------------------------------------------------------------------------
@@ -605,3 +669,29 @@ export const workflowJobLogsCommand = Command.make(
605
669
  "Fetch parsed, clean logs for a specific job in a workflow run. Resolves job name to ID, strips timestamps/ANSI, groups by step.",
606
670
  ),
607
671
  );
672
+
673
+ export const workflowAnnotationsCommand = Command.make(
674
+ "annotations",
675
+ {
676
+ format: formatOption,
677
+ job: Flag.string("job").pipe(
678
+ Flag.withDescription("Filter to a specific job name (exact or partial match)"),
679
+ Flag.optional,
680
+ ),
681
+ repo: repoOption,
682
+ run: Flag.integer("run").pipe(Flag.withDescription("Workflow run ID")),
683
+ },
684
+ ({ format, job, repo, run }) =>
685
+ Effect.gen(function* () {
686
+ const result = yield* fetchAnnotations({
687
+ runId: run,
688
+ job: Option.getOrNull(job),
689
+ repo: Option.getOrNull(repo),
690
+ });
691
+ yield* logFormatted(result, format);
692
+ }),
693
+ ).pipe(
694
+ Command.withDescription(
695
+ "List annotations (errors, warnings, notices) from check runs in a workflow run. Shows problem matcher output, test failures, and other CI annotations.",
696
+ ),
697
+ );
@@ -0,0 +1,272 @@
1
+ import { Effect } from "effect";
2
+ import { readdir } from "node:fs/promises";
3
+
4
+ import type { MessageSummary } from "./types";
5
+
6
+ import { SessionReadError, SessionStorageNotFoundError, type SessionError } from "./errors";
7
+
8
+ export const encodeProjectPath = (dir: string): string => dir.replaceAll("/", "-");
9
+
10
+ export type ContentBlock =
11
+ | { type: "text"; text: string }
12
+ | { type: "thinking"; thinking: string }
13
+ | { type: "tool_use"; id: string; name: string; input: unknown }
14
+ | { type: "tool_result"; tool_use_id: string; content: string };
15
+
16
+ export type ClaudeCodeRecord =
17
+ | { type: "summary"; summary: string }
18
+ | {
19
+ type: "user";
20
+ timestamp: string;
21
+ uuid: string;
22
+ message: { role: "user"; content: string | ReadonlyArray<ContentBlock> };
23
+ }
24
+ | {
25
+ type: "assistant";
26
+ timestamp: string;
27
+ uuid: string;
28
+ message: { role: "assistant"; content: ReadonlyArray<ContentBlock> };
29
+ };
30
+
31
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
32
+ typeof value === "object" && value !== null;
33
+
34
+ const isContentBlock = (value: unknown): value is ContentBlock => {
35
+ if (!isRecord(value) || typeof value.type !== "string") {
36
+ return false;
37
+ }
38
+
39
+ switch (value.type) {
40
+ case "text":
41
+ return typeof value.text === "string";
42
+ case "thinking":
43
+ return typeof value.thinking === "string";
44
+ case "tool_use":
45
+ return typeof value.id === "string" && typeof value.name === "string";
46
+ case "tool_result":
47
+ return typeof value.tool_use_id === "string" && typeof value.content === "string";
48
+ default:
49
+ return false;
50
+ }
51
+ };
52
+
53
+ const isUserRecord = (
54
+ value: Record<string, unknown>,
55
+ ): value is Extract<ClaudeCodeRecord, { type: "user" }> => {
56
+ if (
57
+ value.type !== "user" ||
58
+ typeof value.timestamp !== "string" ||
59
+ typeof value.uuid !== "string"
60
+ ) {
61
+ return false;
62
+ }
63
+
64
+ if (!isRecord(value.message) || value.message.role !== "user") {
65
+ return false;
66
+ }
67
+
68
+ if (typeof value.message.content === "string") {
69
+ return true;
70
+ }
71
+
72
+ return Array.isArray(value.message.content) && value.message.content.every(isContentBlock);
73
+ };
74
+
75
+ const isAssistantRecord = (
76
+ value: Record<string, unknown>,
77
+ ): value is Extract<ClaudeCodeRecord, { type: "assistant" }> => {
78
+ if (
79
+ value.type !== "assistant" ||
80
+ typeof value.timestamp !== "string" ||
81
+ typeof value.uuid !== "string" ||
82
+ !isRecord(value.message) ||
83
+ value.message.role !== "assistant"
84
+ ) {
85
+ return false;
86
+ }
87
+
88
+ return Array.isArray(value.message.content) && value.message.content.every(isContentBlock);
89
+ };
90
+
91
+ export const parseJsonlLine = (line: string): ClaudeCodeRecord | null => {
92
+ let parsed: unknown;
93
+ try {
94
+ parsed = JSON.parse(line);
95
+ } catch {
96
+ return null;
97
+ }
98
+
99
+ if (!isRecord(parsed) || typeof parsed.type !== "string") {
100
+ return null;
101
+ }
102
+
103
+ if (parsed.type === "summary" && typeof parsed.summary === "string") {
104
+ return {
105
+ type: "summary",
106
+ summary: parsed.summary,
107
+ };
108
+ }
109
+
110
+ if (isUserRecord(parsed)) {
111
+ return parsed;
112
+ }
113
+
114
+ if (isAssistantRecord(parsed)) {
115
+ return parsed;
116
+ }
117
+
118
+ if (
119
+ parsed.type === "system" ||
120
+ parsed.type === "progress" ||
121
+ parsed.type === "file-history-snapshot" ||
122
+ parsed.type === "queue-operation"
123
+ ) {
124
+ return null;
125
+ }
126
+
127
+ return null;
128
+ };
129
+
130
+ export const extractTextFromContent = (content: string | ReadonlyArray<ContentBlock>): string => {
131
+ if (typeof content === "string") {
132
+ return content;
133
+ }
134
+
135
+ return content
136
+ .filter((block): block is Extract<ContentBlock, { type: "text" }> => block.type === "text")
137
+ .map((block) => block.text)
138
+ .join("\n");
139
+ };
140
+
141
+ export const extractSessionTitle = (records: ReadonlyArray<ClaudeCodeRecord>): string => {
142
+ const summaryRecord = records.find((record) => record.type === "summary");
143
+ if (summaryRecord !== undefined) {
144
+ return summaryRecord.summary;
145
+ }
146
+
147
+ const firstUserRecord = records.find((record) => record.type === "user");
148
+ if (firstUserRecord !== undefined) {
149
+ return extractTextFromContent(firstUserRecord.message.content).slice(0, 100);
150
+ }
151
+
152
+ return "Untitled session";
153
+ };
154
+
155
+ export const getClaudeCodeSessions = (
156
+ basePath: string,
157
+ projectDir: string | null,
158
+ ): Effect.Effect<string[], SessionError> =>
159
+ Effect.tryPromise({
160
+ try: async () => {
161
+ const targetProjectDir = projectDir === null ? null : encodeProjectPath(projectDir);
162
+ const projectEntries = await readdir(basePath, { withFileTypes: true });
163
+
164
+ const sessionFiles: string[] = [];
165
+
166
+ for (const entry of projectEntries) {
167
+ if (!entry.isDirectory()) {
168
+ continue;
169
+ }
170
+
171
+ if (targetProjectDir !== null && entry.name !== targetProjectDir) {
172
+ continue;
173
+ }
174
+
175
+ const projectPath = `${basePath}/${entry.name}`;
176
+ let files: string[];
177
+ try {
178
+ // eslint-disable-next-line eslint/no-await-in-loop -- directory scan must finish before filtering file list
179
+ files = await readdir(projectPath);
180
+ } catch {
181
+ continue;
182
+ }
183
+
184
+ for (const fileName of files) {
185
+ if (!fileName.endsWith(".jsonl") || fileName.startsWith("agent-")) {
186
+ continue;
187
+ }
188
+
189
+ sessionFiles.push(`${projectPath}/${fileName}`);
190
+ }
191
+ }
192
+
193
+ return sessionFiles;
194
+ },
195
+ catch: (error) =>
196
+ new SessionStorageNotFoundError({
197
+ message: error instanceof Error ? error.message : "Claude Code storage directory not found",
198
+ path: basePath,
199
+ }),
200
+ });
201
+
202
+ const getFileNameWithoutExtension = (filePath: string): string => {
203
+ const fileName = filePath.split("/").pop();
204
+ if (fileName === undefined) {
205
+ return "";
206
+ }
207
+
208
+ if (!fileName.endsWith(".jsonl")) {
209
+ return fileName;
210
+ }
211
+
212
+ return fileName.slice(0, -".jsonl".length);
213
+ };
214
+
215
+ export const readClaudeCodeMessages = (
216
+ sessionFiles: string[],
217
+ ): Effect.Effect<MessageSummary[], SessionError> =>
218
+ Effect.tryPromise({
219
+ try: async () => {
220
+ const summaries: MessageSummary[] = [];
221
+
222
+ for (const sessionFile of sessionFiles) {
223
+ let fileContent: string;
224
+ try {
225
+ // eslint-disable-next-line eslint/no-await-in-loop -- sequential file read keeps memory bounded
226
+ fileContent = await Bun.file(sessionFile).text();
227
+ } catch {
228
+ continue;
229
+ }
230
+
231
+ const records = fileContent
232
+ .split(/\r?\n/u)
233
+ .map((line) => line.trim())
234
+ .filter((line) => line.length > 0)
235
+ .map(parseJsonlLine)
236
+ .filter((record): record is ClaudeCodeRecord => record !== null);
237
+
238
+ const title = extractSessionTitle(records);
239
+ const sessionID = getFileNameWithoutExtension(sessionFile);
240
+
241
+ for (const record of records) {
242
+ if (record.type !== "user" && record.type !== "assistant") {
243
+ continue;
244
+ }
245
+
246
+ const createdTimestamp = new Date(record.timestamp).getTime();
247
+ summaries.push({
248
+ sessionID,
249
+ id: record.uuid,
250
+ title,
251
+ body: extractTextFromContent(record.message.content),
252
+ created: Number.isFinite(createdTimestamp) ? createdTimestamp : 0,
253
+ role: record.type,
254
+ source: "claude-code",
255
+ });
256
+ }
257
+ }
258
+
259
+ return (
260
+ summaries as MessageSummary[] & {
261
+ toSorted(
262
+ compareFn: (left: MessageSummary, right: MessageSummary) => number,
263
+ ): MessageSummary[];
264
+ }
265
+ ).toSorted((left, right) => right.created - left.created);
266
+ },
267
+ catch: (error) =>
268
+ new SessionReadError({
269
+ message: error instanceof Error ? error.message : "Failed to read Claude Code sessions",
270
+ source: "claude-code",
271
+ }),
272
+ });
@@ -1,4 +1,5 @@
1
1
  import { Effect, Layer, ServiceMap } from "effect";
2
+ import { existsSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
5
 
@@ -42,6 +43,7 @@ export class ResolvedPaths extends ServiceMap.Service<
42
43
  {
43
44
  readonly messagesPath: string;
44
45
  readonly sessionsPath: string;
46
+ readonly claudeCodePath: string | null;
45
47
  }
46
48
  >()("@agent-tools/ResolvedPaths") {}
47
49
 
@@ -50,6 +52,8 @@ export const ResolvedPathsLayer = Layer.effect(
50
52
  Effect.gen(function* () {
51
53
  const messagesPath = yield* resolveMessagesPath;
52
54
  const sessionsPath = yield* resolveSessionsPath;
53
- return { messagesPath, sessionsPath };
55
+ const claudeCodeBasePath = join(homedir(), ".claude/projects");
56
+ const claudeCodePath = existsSync(claudeCodeBasePath) ? claudeCodeBasePath : null;
57
+ return { messagesPath, sessionsPath, claudeCodePath };
54
58
  }),
55
59
  );
@@ -11,7 +11,7 @@ import { Argument, Command, Flag } from "effect/unstable/cli";
11
11
  import { BunRuntime, BunServices } from "@effect/platform-bun";
12
12
  import { Console, Effect, Layer, Result } from "effect";
13
13
 
14
- import type { MessageSummary, SessionResult } from "./types";
14
+ import type { MessageSummary, SessionResult, SessionSource } from "./types";
15
15
 
16
16
  import { formatOption, formatOutput, VERSION } from "#shared";
17
17
  import { AuditServiceLayer, withAudit } from "#shared/audit";
@@ -21,6 +21,16 @@ import { formatDate, SessionService, SessionServiceLayer, truncate } from "./ser
21
21
 
22
22
  const AppLayer = SessionServiceLayer.pipe(Layer.provideMerge(ResolvedPathsLayer));
23
23
 
24
+ const sourceOption = Flag.string("source").pipe(
25
+ Flag.withDescription("Filter by source: all, opencode, claude-code"),
26
+ Flag.withDefault("all"),
27
+ );
28
+
29
+ const filterBySource = (summaries: MessageSummary[], source: string): MessageSummary[] => {
30
+ if (source === "all") return summaries;
31
+ return summaries.filter((s) => s.source === (source as SessionSource));
32
+ };
33
+
24
34
  const buildScopeLabel = (searchAll: boolean, currentDir: string) => {
25
35
  if (searchAll) {
26
36
  return "all projects";
@@ -39,9 +49,14 @@ const mapSummary = (summary: MessageSummary) => {
39
49
  title: summary.title,
40
50
  body: truncate(summary.body, 500),
41
51
  created: formatDate(summary.created),
42
- messagePath: `${paths.messagesPath}/${summary.sessionID}/${summary.id}.json`,
52
+ ...(summary.source === "opencode"
53
+ ? {
54
+ messagePath: `${paths.messagesPath}/${summary.sessionID}/${summary.id}.json`,
55
+ sessionPath: `${paths.messagesPath}/${summary.sessionID}`,
56
+ }
57
+ : {}),
43
58
  role: summary.role,
44
- sessionPath: `${paths.messagesPath}/${summary.sessionID}`,
59
+ source: summary.source,
45
60
  };
46
61
  });
47
62
  };
@@ -58,8 +73,9 @@ const listCommand = Command.make(
58
73
  Flag.withDescription("Limit result count"),
59
74
  Flag.withDefault(10),
60
75
  ),
76
+ source: sourceOption,
61
77
  },
62
- ({ all, format, limit }) =>
78
+ ({ all, format, limit, source }) =>
63
79
  Effect.gen(function* () {
64
80
  const sessionService = yield* SessionService;
65
81
  const startTime = Date.now();
@@ -83,11 +99,13 @@ const listCommand = Command.make(
83
99
  } satisfies SessionResult;
84
100
  }
85
101
 
86
- const summaries = yield* sessionService.getMessageSummaries(sessionFilter);
102
+ const allSummaries = yield* sessionService.getMessageSummaries(sessionFilter);
103
+ const summaries = filterBySource(allSummaries, source);
87
104
  const results = summaries.slice(0, limit).map((summary) => ({
88
105
  created: formatDate(summary.created),
89
106
  sessionID: summary.sessionID,
90
107
  title: summary.title,
108
+ source: summary.source,
91
109
  }));
92
110
 
93
111
  return {
@@ -132,8 +150,9 @@ const searchCommand = Command.make(
132
150
  Flag.withDescription("Limit result count"),
133
151
  Flag.withDefault(10),
134
152
  ),
153
+ source: sourceOption,
135
154
  },
136
- ({ all, format, limit, query }) =>
155
+ ({ all, format, limit, query, source }) =>
137
156
  Effect.gen(function* () {
138
157
  const sessionService = yield* SessionService;
139
158
  const startTime = Date.now();
@@ -158,7 +177,8 @@ const searchCommand = Command.make(
158
177
  } satisfies SessionResult;
159
178
  }
160
179
 
161
- const summaries = yield* sessionService.getMessageSummaries(sessionFilter);
180
+ const allSummaries = yield* sessionService.getMessageSummaries(sessionFilter);
181
+ const summaries = filterBySource(allSummaries, source);
162
182
  const matched = sessionService.searchSummaries(summaries, query);
163
183
  const mappedResults = yield* Effect.all(matched.slice(0, limit).map(mapSummary));
164
184
 
@@ -196,15 +216,16 @@ const searchCommand = Command.make(
196
216
 
197
217
  yield* Console.log(formatOutput(output, format));
198
218
  }),
199
- ).pipe(Command.withDescription("Search OpenCode message history"));
219
+ ).pipe(Command.withDescription("Search message history"));
200
220
 
201
221
  const readCommand = Command.make(
202
222
  "read",
203
223
  {
204
224
  session: Flag.string("session").pipe(Flag.withDescription("Session ID to read")),
205
225
  format: formatOption,
226
+ source: sourceOption,
206
227
  },
207
- ({ format, session }) =>
228
+ ({ format, session, source }) =>
208
229
  Effect.gen(function* () {
209
230
  const sessionService = yield* SessionService;
210
231
  const startTime = Date.now();
@@ -226,14 +247,19 @@ const readCommand = Command.make(
226
247
  executionTimeMs: Date.now() - startTime,
227
248
  } satisfies SessionResult),
228
249
  onSuccess: (summaries) => {
229
- const sessionResults = summaries.filter((summary) => summary.sessionID === session);
250
+ const filtered = filterBySource(summaries, source);
251
+ const sessionResults = filtered.filter((summary) => summary.sessionID === session);
230
252
  return Effect.all(sessionResults.map(mapSummary)).pipe(
231
253
  Effect.map(
232
254
  (mapped) =>
233
255
  ({
234
256
  success: true,
235
257
  data: {
236
- files: mapped.map((message) => message.messagePath),
258
+ files: mapped
259
+ .map((message) =>
260
+ "messagePath" in message ? (message.messagePath as string) : null,
261
+ )
262
+ .filter((filePath): filePath is string => filePath !== null),
237
263
  messages: mapped,
238
264
  session,
239
265
  },
@@ -3,6 +3,7 @@ import { readdir } from "node:fs/promises";
3
3
 
4
4
  import type { MessageSummary, SessionInfo } from "./types";
5
5
 
6
+ import { getClaudeCodeSessions, readClaudeCodeMessages } from "./claude-code";
6
7
  import { ResolvedPaths } from "./config";
7
8
  import { SessionReadError, SessionStorageNotFoundError, type SessionError } from "./errors";
8
9
 
@@ -37,6 +38,37 @@ export const truncate = (value: string, maxLen: number): string => {
37
38
 
38
39
  type FileEntry = { filePath: string; content: string };
39
40
 
41
+ type SourceFilter = "both" | "opencode" | "claude-code";
42
+
43
+ const UUID_SESSION_ID_REGEX =
44
+ /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/u;
45
+
46
+ const getSessionIdFromClaudeFile = (filePath: string): string => {
47
+ const fileName = filePath.split("/").pop() ?? "";
48
+ return fileName.endsWith(".jsonl") ? fileName.slice(0, -".jsonl".length) : fileName;
49
+ };
50
+
51
+ const detectSourceFilter = (filterSessions: Set<string> | null): SourceFilter => {
52
+ if (filterSessions === null || filterSessions.size !== 1) {
53
+ return "both";
54
+ }
55
+
56
+ const sessionId = filterSessions.values().next().value;
57
+ if (typeof sessionId !== "string") {
58
+ return "both";
59
+ }
60
+
61
+ if (sessionId.startsWith("ses_")) {
62
+ return "opencode";
63
+ }
64
+
65
+ if (UUID_SESSION_ID_REGEX.test(sessionId)) {
66
+ return "claude-code";
67
+ }
68
+
69
+ return "both";
70
+ };
71
+
40
72
  /**
41
73
  * Reads JSON files from a two-level directory (parent/sub/*.json) using Bun.file().
42
74
  * Required for ~100k OpenCode message files where shell-per-file would timeout.
@@ -131,16 +163,42 @@ export class SessionService extends ServiceMap.Service<
131
163
  getSessionsForProject: Effect.fn("SessionService.getSessionsForProject")(function* (
132
164
  projectDir: string | null,
133
165
  ) {
134
- const files = yield* readJsonFilesInTree(paths.sessionsPath);
135
- const matchingSessions = new Set<string>();
166
+ const opencodeSessions = yield* Effect.gen(function* () {
167
+ const files = yield* readJsonFilesInTree(paths.sessionsPath);
168
+ const matchingSessions = new Set<string>();
136
169
 
137
- for (const { content } of files) {
138
- const parsed = parseJson<SessionInfo>(content);
139
- if (parsed === null) continue;
170
+ for (const { content } of files) {
171
+ const parsed = parseJson<SessionInfo>(content);
172
+ if (parsed === null) continue;
140
173
 
141
- if (projectDir === null || parsed.directory === projectDir) {
142
- matchingSessions.add(parsed.id);
174
+ if (projectDir === null || parsed.directory === projectDir) {
175
+ matchingSessions.add(parsed.id);
176
+ }
143
177
  }
178
+
179
+ return matchingSessions;
180
+ }).pipe(
181
+ Effect.catchTag("SessionStorageNotFoundError", () => Effect.succeed(new Set<string>())),
182
+ );
183
+
184
+ const claudeSessions =
185
+ paths.claudeCodePath === null
186
+ ? new Set<string>()
187
+ : yield* getClaudeCodeSessions(paths.claudeCodePath, projectDir).pipe(
188
+ Effect.map(
189
+ (files) =>
190
+ new Set<string>(
191
+ files.map((filePath) => getSessionIdFromClaudeFile(filePath)),
192
+ ),
193
+ ),
194
+ Effect.catchTag("SessionStorageNotFoundError", () =>
195
+ Effect.succeed(new Set<string>()),
196
+ ),
197
+ );
198
+
199
+ const matchingSessions = new Set<string>(opencodeSessions);
200
+ for (const sessionId of claudeSessions) {
201
+ matchingSessions.add(sessionId);
144
202
  }
145
203
 
146
204
  return matchingSessions;
@@ -149,54 +207,81 @@ export class SessionService extends ServiceMap.Service<
149
207
  getMessageSummaries: Effect.fn("SessionService.getMessageSummaries")(function* (
150
208
  filterSessions: Set<string> | null,
151
209
  ) {
152
- const sessionDirs = yield* Effect.tryPromise({
153
- try: async () => {
154
- const dirs = await readdir(paths.messagesPath);
155
- return dirs
156
- .filter((name) => name.startsWith("ses_"))
157
- .filter((name) => filterSessions === null || filterSessions.has(name));
158
- },
159
- catch: () =>
160
- new SessionStorageNotFoundError({
161
- message: "Message storage directory not found",
162
- path: paths.messagesPath,
163
- }),
164
- });
210
+ const sourceFilter = detectSourceFilter(filterSessions);
165
211
 
166
- const summaries: MessageSummary[] = [];
167
-
168
- for (const sessionId of sessionDirs) {
169
- const sessionPath = `${paths.messagesPath}/${sessionId}`;
170
- const files = yield* readJsonFilesFlat(sessionPath);
171
-
172
- for (const { filePath, content } of files) {
173
- const parsed = parseJson<{
174
- id?: string;
175
- role?: string;
176
- sessionID?: string;
177
- summary?: {
178
- body?: string;
179
- title?: string;
180
- };
181
- time?: {
182
- created?: number;
183
- };
184
- }>(content);
185
-
186
- if (parsed === null || parsed.summary?.title === undefined) {
187
- continue;
188
- }
212
+ const opencodeSummaries =
213
+ sourceFilter === "claude-code"
214
+ ? []
215
+ : yield* Effect.gen(function* () {
216
+ const sessionDirs = yield* Effect.tryPromise({
217
+ try: async () => {
218
+ const dirs = await readdir(paths.messagesPath);
219
+ return dirs
220
+ .filter((name) => name.startsWith("ses_"))
221
+ .filter((name) => filterSessions === null || filterSessions.has(name));
222
+ },
223
+ catch: () =>
224
+ new SessionStorageNotFoundError({
225
+ message: "Message storage directory not found",
226
+ path: paths.messagesPath,
227
+ }),
228
+ });
189
229
 
190
- summaries.push({
191
- sessionID: parsed.sessionID ?? sessionId,
192
- id: parsed.id ?? filePath.split("/").pop()?.replace(".json", "") ?? "",
193
- title: parsed.summary.title,
194
- body: parsed.summary.body ?? "",
195
- created: parsed.time?.created ?? 0,
196
- role: parsed.role ?? "unknown",
197
- });
198
- }
199
- }
230
+ const summaries: MessageSummary[] = [];
231
+
232
+ for (const sessionId of sessionDirs) {
233
+ const sessionPath = `${paths.messagesPath}/${sessionId}`;
234
+ const files = yield* readJsonFilesFlat(sessionPath);
235
+
236
+ for (const { filePath, content } of files) {
237
+ const parsed = parseJson<{
238
+ id?: string;
239
+ role?: string;
240
+ sessionID?: string;
241
+ summary?: {
242
+ body?: string;
243
+ title?: string;
244
+ };
245
+ time?: {
246
+ created?: number;
247
+ };
248
+ }>(content);
249
+
250
+ if (parsed === null || parsed.summary?.title === undefined) {
251
+ continue;
252
+ }
253
+
254
+ summaries.push({
255
+ sessionID: parsed.sessionID ?? sessionId,
256
+ id: parsed.id ?? filePath.split("/").pop()?.replace(".json", "") ?? "",
257
+ title: parsed.summary.title,
258
+ body: parsed.summary.body ?? "",
259
+ created: parsed.time?.created ?? 0,
260
+ role: parsed.role ?? "unknown",
261
+ source: "opencode",
262
+ });
263
+ }
264
+ }
265
+
266
+ return summaries;
267
+ }).pipe(Effect.catchTag("SessionStorageNotFoundError", () => Effect.succeed([])));
268
+
269
+ const claudeSummaries =
270
+ sourceFilter === "opencode" || paths.claudeCodePath === null
271
+ ? []
272
+ : yield* getClaudeCodeSessions(paths.claudeCodePath, null).pipe(
273
+ Effect.map((sessionFiles) =>
274
+ filterSessions === null
275
+ ? sessionFiles
276
+ : sessionFiles.filter((sessionFile) =>
277
+ filterSessions.has(getSessionIdFromClaudeFile(sessionFile)),
278
+ ),
279
+ ),
280
+ Effect.flatMap(readClaudeCodeMessages),
281
+ Effect.catchTag("SessionStorageNotFoundError", () => Effect.succeed([])),
282
+ );
283
+
284
+ const summaries = [...opencodeSummaries, ...claudeSummaries];
200
285
 
201
286
  return (
202
287
  summaries as MessageSummary[] & {
@@ -8,6 +8,8 @@ export type SessionInfo = {
8
8
  projectID: string;
9
9
  };
10
10
 
11
+ export type SessionSource = "opencode" | "claude-code";
12
+
11
13
  export type MessageSummary = {
12
14
  sessionID: string;
13
15
  id: string;
@@ -15,6 +17,7 @@ export type MessageSummary = {
15
17
  body: string;
16
18
  created: number;
17
19
  role: string;
20
+ source: SessionSource;
18
21
  };
19
22
 
20
23
  export type SessionResult = {