opencode-codetime 0.3.0 → 0.5.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
@@ -8,13 +8,17 @@
8
8
 
9
9
  [CodeTime](https://codetime.dev) plugin for [OpenCode](https://github.com/anomalyco/opencode) -- track your AI coding activity and time spent.
10
10
 
11
+ <img width="885" height="312" alt="image" src="https://github.com/user-attachments/assets/5f15a838-af86-4d4c-ab07-a9e467779bca" />
12
+
11
13
  ## Features
12
14
 
13
15
  - **Automatic time tracking** -- sends coding events to CodeTime when OpenCode reads, edits, or writes files
14
16
  - **Check your coding time** -- ask the AI "what's my coding time?" and it fetches your stats via the `codetime` tool
17
+ - **Per-project filtering** -- view coding time for the current project or any specific project
18
+ - **Project breakdown** -- see a ranked table of all projects with time spent today
15
19
  - **Language detection** -- detects 90+ programming languages from file extensions
16
20
  - **Git integration** -- captures current branch and remote origin
17
- - **Project identification** -- shows as `[opencode] project-name` on your CodeTime dashboard
21
+ - **Project identification** -- shows as `directory-name [opencode]` on your CodeTime dashboard
18
22
  - **Rate-limited** -- one heartbeat per 2 minutes to avoid API spam
19
23
  - **Session lifecycle** -- flushes pending events on session end so no data is lost
20
24
  - **Zero config** -- just set your token and go
@@ -89,7 +93,7 @@ Each heartbeat sent to CodeTime includes:
89
93
  |-------|-------|
90
94
  | `eventTime` | Unix timestamp of the event |
91
95
  | `language` | Detected from file extension (e.g. `TypeScript`, `Python`) |
92
- | `project` | `[opencode] directory-name` |
96
+ | `project` | `directory-name [opencode]` |
93
97
  | `relativeFile` | File path relative to the project root |
94
98
  | `editor` | `opencode` |
95
99
  | `platform` | OS platform (`darwin`, `linux`, `windows`) |
@@ -104,6 +108,48 @@ Each heartbeat sent to CodeTime includes:
104
108
  | `chat.message` | Processes pending heartbeats on chat activity |
105
109
  | `tool` | Registers `codetime` tool to check today's coding time |
106
110
 
111
+ ### Commands
112
+
113
+ | Command | Description |
114
+ |---------|-------------|
115
+ | `/codetime` | Show today's total coding time |
116
+ | `/codetime-breakdown` | Show today's coding time broken down by project |
117
+
118
+ ### `codetime` tool
119
+
120
+ The `codetime` tool supports optional arguments for project filtering:
121
+
122
+ | Argument | Type | Description |
123
+ |----------|------|-------------|
124
+ | `project` | string (optional) | Filter by project name. Use `"current"` to auto-detect the current project. Omit to show total time. |
125
+ | `breakdown` | boolean (optional) | When `true`, show a ranked breakdown of all projects. |
126
+
127
+ **Usage examples** (in natural language to the AI):
128
+
129
+ - "What's my coding time?" -- shows total time across all projects
130
+ - "How long have I been coding on this project?" -- shows time for the current project
131
+ - "Show me a breakdown of my coding time by project" -- shows ranked project list
132
+ - "How much time did I spend on my-app today?" -- shows time for a specific project
133
+
134
+ **Example outputs:**
135
+
136
+ ```
137
+ # Total time (default)
138
+ Today's coding time: 2h 42m
139
+
140
+ # Current project
141
+ Today's coding time for opencode-codetime [opencode]: 1h 23m (Total across all projects: 2h 42m)
142
+
143
+ # Project breakdown
144
+ Today's coding time by project:
145
+
146
+ opencode-codetime [opencode] 1h 23m
147
+ my-other-project [vscode] 52m
148
+ side-project [opencode] 27m
149
+ ──────────────────────────────────────
150
+ Total 2h 42m
151
+ ```
152
+
107
153
  ### Tool tracking
108
154
 
109
155
  | Tool | Data Extracted |
@@ -119,7 +165,7 @@ Each heartbeat sent to CodeTime includes:
119
165
  ```
120
166
  src/
121
167
  index.ts Main plugin entry point with event hooks
122
- codetime.ts CodeTime API client (token validation, heartbeats)
168
+ codetime.ts CodeTime API client (token validation, heartbeats, stats)
123
169
  state.ts Rate limiter (max 1 heartbeat per 2 minutes)
124
170
  language.ts File extension to language name mapping
125
171
  git.ts Git branch and remote origin extraction
@@ -28,3 +28,25 @@ export declare function sendHeartbeat(token: string, event: EventLogRequest): Pr
28
28
  * Fetch today's coding minutes from CodeTime API.
29
29
  */
30
30
  export declare function getTodayMinutes(token: string): Promise<number | null>;
31
+ export interface StatsTimeData {
32
+ duration: number;
33
+ time: string;
34
+ }
35
+ export interface StatsTimeResponse {
36
+ data: StatsTimeData[];
37
+ }
38
+ export interface TopEntry {
39
+ field: string;
40
+ minutes: number;
41
+ }
42
+ /**
43
+ * Fetch coding minutes for a specific project using the stats_time endpoint.
44
+ * Returns total minutes for the project within the last 24 hours,
45
+ * or a custom time range if start/end are provided.
46
+ */
47
+ export declare function getProjectMinutes(token: string, project: string): Promise<number | null>;
48
+ /**
49
+ * Fetch top projects by coding time using the top endpoint.
50
+ * Returns a ranked list of projects with their minutes.
51
+ */
52
+ export declare function getTopProjects(token: string, minutes?: number): Promise<TopEntry[] | null>;
package/dist/codetime.js CHANGED
@@ -83,3 +83,77 @@ export async function getTodayMinutes(token) {
83
83
  return null;
84
84
  }
85
85
  }
86
+ /**
87
+ * Fetch coding minutes for a specific project using the stats_time endpoint.
88
+ * Returns total minutes for the project within the last 24 hours,
89
+ * or a custom time range if start/end are provided.
90
+ */
91
+ export async function getProjectMinutes(token, project) {
92
+ try {
93
+ const params = new URLSearchParams({
94
+ project,
95
+ unit: "minutes",
96
+ limit: "1440",
97
+ });
98
+ const response = await fetch(`${API_BASE}/v3/users/self/stats_time?${params.toString()}`, {
99
+ method: "GET",
100
+ headers: {
101
+ Authorization: `Bearer ${token}`,
102
+ "Content-Type": "application/json",
103
+ },
104
+ });
105
+ if (!response.ok) {
106
+ await logger.warn("Failed to fetch project minutes", {
107
+ status: response.status,
108
+ statusText: response.statusText,
109
+ project,
110
+ });
111
+ return null;
112
+ }
113
+ const data = (await response.json());
114
+ // Sum all duration values from the response
115
+ const totalMinutes = data.data.reduce((sum, entry) => sum + entry.duration, 0);
116
+ return totalMinutes;
117
+ }
118
+ catch (err) {
119
+ await logger.error("Project minutes request failed", {
120
+ error: String(err),
121
+ project,
122
+ });
123
+ return null;
124
+ }
125
+ }
126
+ /**
127
+ * Fetch top projects by coding time using the top endpoint.
128
+ * Returns a ranked list of projects with their minutes.
129
+ */
130
+ export async function getTopProjects(token, minutes = 1440) {
131
+ try {
132
+ const params = new URLSearchParams({
133
+ field: "workspace",
134
+ minutes: String(minutes),
135
+ });
136
+ const response = await fetch(`${API_BASE}/v3/users/self/top?${params.toString()}`, {
137
+ method: "GET",
138
+ headers: {
139
+ Authorization: `Bearer ${token}`,
140
+ "Content-Type": "application/json",
141
+ },
142
+ });
143
+ if (!response.ok) {
144
+ await logger.warn("Failed to fetch top projects", {
145
+ status: response.status,
146
+ statusText: response.statusText,
147
+ });
148
+ return null;
149
+ }
150
+ const data = (await response.json());
151
+ return data;
152
+ }
153
+ catch (err) {
154
+ await logger.error("Top projects request failed", {
155
+ error: String(err),
156
+ });
157
+ return null;
158
+ }
159
+ }
package/dist/git.d.ts CHANGED
@@ -1,15 +1,10 @@
1
- interface ShellFn {
2
- (strings: TemplateStringsArray, ...values: unknown[]): Promise<{
3
- stdout: Buffer;
4
- exitCode: number;
5
- }>;
6
- }
7
1
  /**
8
2
  * Extract git remote origin URL from the worktree.
3
+ * Uses node:child_process directly to avoid OpenCode TUI shell flash.
9
4
  */
10
- export declare function getGitOrigin($: ShellFn, worktree: string): Promise<string | null>;
5
+ export declare function getGitOrigin(worktree: string): Promise<string | null>;
11
6
  /**
12
7
  * Extract the current git branch from the worktree.
8
+ * Uses node:child_process directly to avoid OpenCode TUI shell flash.
13
9
  */
14
- export declare function getGitBranch($: ShellFn, worktree: string): Promise<string | null>;
15
- export {};
10
+ export declare function getGitBranch(worktree: string): Promise<string | null>;
package/dist/git.js CHANGED
@@ -1,32 +1,42 @@
1
- import * as debug from "./logger.js";
1
+ import { execFile as execFileCb } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFile = promisify(execFileCb);
2
4
  /**
3
5
  * Extract git remote origin URL from the worktree.
6
+ * Uses node:child_process directly to avoid OpenCode TUI shell flash.
4
7
  */
5
- export async function getGitOrigin($, worktree) {
8
+ export async function getGitOrigin(worktree) {
6
9
  try {
7
- const result = await $ `git -C ${worktree} config --get remote.origin.url 2>/dev/null`;
8
- if (result.exitCode === 0) {
9
- return result.stdout.toString().trim() || null;
10
- }
10
+ const { stdout } = await execFile("git", [
11
+ "-C",
12
+ worktree,
13
+ "config",
14
+ "--get",
15
+ "remote.origin.url",
16
+ ]);
17
+ return stdout.trim() || null;
11
18
  }
12
19
  catch {
13
- await debug.debug("Failed to get git origin", { worktree });
20
+ return null;
14
21
  }
15
- return null;
16
22
  }
17
23
  /**
18
24
  * Extract the current git branch from the worktree.
25
+ * Uses node:child_process directly to avoid OpenCode TUI shell flash.
19
26
  */
20
- export async function getGitBranch($, worktree) {
27
+ export async function getGitBranch(worktree) {
21
28
  try {
22
- const result = await $ `git -C ${worktree} rev-parse --abbrev-ref HEAD 2>/dev/null`;
23
- if (result.exitCode === 0) {
24
- const branch = result.stdout.toString().trim();
25
- return branch && branch !== "HEAD" ? branch : null;
26
- }
29
+ const { stdout } = await execFile("git", [
30
+ "-C",
31
+ worktree,
32
+ "rev-parse",
33
+ "--abbrev-ref",
34
+ "HEAD",
35
+ ]);
36
+ const branch = stdout.trim();
37
+ return branch && branch !== "HEAD" ? branch : null;
27
38
  }
28
39
  catch {
29
- await debug.debug("Failed to get git branch", { worktree });
40
+ return null;
30
41
  }
31
- return null;
32
42
  }
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as os from "node:os";
2
2
  import * as path from "node:path";
3
3
  import { tool } from "@opencode-ai/plugin/tool";
4
- import { sendHeartbeat, validateToken, getTodayMinutes, } from "./codetime.js";
4
+ import { sendHeartbeat, validateToken, getTodayMinutes, getProjectMinutes, getTopProjects, } from "./codetime.js";
5
5
  import { getGitBranch, getGitOrigin } from "./git.js";
6
6
  import { detectLanguage } from "./language.js";
7
7
  import { initLogger, info, warn, error, debug } from "./logger.js";
@@ -10,11 +10,9 @@ import { initState, shouldSendHeartbeat, updateLastHeartbeat, } from "./state.js
10
10
  const processedCallIds = new Set();
11
11
  const pendingFiles = new Map();
12
12
  let _token = null;
13
- let _client = null;
14
13
  let _projectName = "unknown";
15
14
  let _projectDir = "";
16
15
  let _worktree = "";
17
- let _shellFn = null;
18
16
  let _gitOrigin = null;
19
17
  let _gitBranch = null;
20
18
  let _gitInfoFetched = false;
@@ -86,12 +84,12 @@ function computeRelativeFile(absoluteFile, projectDir) {
86
84
  }
87
85
  // ---- Lazy git info ----
88
86
  async function ensureGitInfo() {
89
- if (_gitInfoFetched || !_shellFn || !_worktree)
87
+ if (_gitInfoFetched || !_worktree)
90
88
  return;
91
89
  _gitInfoFetched = true;
92
90
  try {
93
- _gitOrigin = await getGitOrigin(_shellFn, _worktree);
94
- _gitBranch = await getGitBranch(_shellFn, _worktree);
91
+ _gitOrigin = await getGitOrigin(_worktree);
92
+ _gitBranch = await getGitBranch(_worktree);
95
93
  await debug("Git info", { origin: _gitOrigin, branch: _gitBranch }).catch(() => { });
96
94
  }
97
95
  catch {
@@ -168,7 +166,7 @@ function formatMinutes(minutes) {
168
166
  // ---- Plugin entry point ----
169
167
  export const plugin = async (ctx) => {
170
168
  try {
171
- const { client, directory, worktree, $ } = ctx;
169
+ const { client, directory, worktree } = ctx;
172
170
  // Initialize logger (may fail if client shape differs)
173
171
  try {
174
172
  initLogger(client);
@@ -195,13 +193,10 @@ export const plugin = async (ctx) => {
195
193
  }).catch(() => { });
196
194
  // Initialize state
197
195
  initState();
198
- _client = client;
199
196
  _projectDir = directory;
200
197
  _worktree = worktree;
201
198
  _projectName = `${path.basename(directory)} [opencode]`;
202
199
  _platform = os.platform();
203
- // Store shell function for lazy git info fetching
204
- _shellFn = $;
205
200
  return {
206
201
  event: async ({ event }) => {
207
202
  try {
@@ -258,55 +253,78 @@ export const plugin = async (ctx) => {
258
253
  await error("Chat message handler error", { error: String(err) }).catch(() => { });
259
254
  }
260
255
  },
261
- "command.execute.before": async (input, output) => {
262
- try {
263
- if (input.command !== "codetime")
264
- return;
265
- if (!_token || !_client) {
266
- await _client?.tui.showToast({
267
- body: { message: "CodeTime: token not configured", variant: "error" },
268
- }).catch(() => { });
269
- output.parts.push({
270
- type: "text",
271
- text: "CodeTime is not configured. Set `CODETIME_TOKEN` environment variable to enable tracking.\nGet your token from https://codetime.dev/dashboard/settings",
272
- });
273
- return;
274
- }
275
- const minutes = await getTodayMinutes(_token);
276
- if (minutes === null) {
277
- await _client.tui.showToast({
278
- body: { message: "CodeTime: failed to fetch data", variant: "error" },
279
- }).catch(() => { });
280
- output.parts.push({
281
- type: "text",
282
- text: "Failed to fetch coding time from CodeTime API.",
283
- });
284
- return;
285
- }
286
- const formatted = formatMinutes(minutes);
287
- await _client.tui.showToast({
288
- body: { message: `CodeTime: ${formatted} today`, variant: "success" },
289
- }).catch(() => { });
290
- output.parts.push({
291
- type: "text",
292
- text: `Today's coding time: ${formatted}`,
293
- });
294
- }
295
- catch (err) {
296
- await error("Command handler error", { error: String(err) }).catch(() => { });
297
- }
256
+ config: async (cfg) => {
257
+ cfg.command = cfg.command || {};
258
+ cfg.command["codetime"] = {
259
+ description: "Show today's coding time from CodeTime",
260
+ template: "Retrieve current CodeTime coding time stats.\n\n" +
261
+ "Immediately call `codetime` with no arguments and return its output verbatim.\n" +
262
+ "Do not call other tools.",
263
+ };
264
+ cfg.command["codetime-breakdown"] = {
265
+ description: "Show today's coding time breakdown by project",
266
+ template: "Retrieve CodeTime coding time stats broken down by project.\n\n" +
267
+ 'Immediately call `codetime` with `breakdown: true` and return its output verbatim.\n' +
268
+ "Do not call other tools.",
269
+ };
298
270
  },
299
271
  tool: {
300
272
  codetime: tool({
301
273
  description: "Show today's coding time tracked by CodeTime. " +
302
274
  "Use this when the user asks about their coding time, " +
303
- "how long they've been coding, or wants to see their CodeTime stats.",
304
- args: {},
305
- async execute() {
275
+ "how long they've been coding, or wants to see their CodeTime stats. " +
276
+ "Supports filtering by project name and showing a breakdown of time across all projects.",
277
+ args: {
278
+ project: tool.schema.string().optional().describe("Filter by project name. Use 'current' to auto-detect the current project. " +
279
+ "Omit to show total time across all projects."),
280
+ breakdown: tool.schema.boolean().optional().describe("When true, show a breakdown of coding time across all projects today."),
281
+ },
282
+ async execute(args) {
306
283
  if (!_token) {
307
284
  return "CodeTime is not configured. Set CODETIME_TOKEN environment variable to enable tracking. Get your token from https://codetime.dev/dashboard/settings";
308
285
  }
309
286
  try {
287
+ // Breakdown mode: show all projects ranked by time
288
+ if (args.breakdown) {
289
+ const projects = await getTopProjects(_token);
290
+ if (projects === null || projects.length === 0) {
291
+ return "No project data available for today.";
292
+ }
293
+ // Calculate total
294
+ const totalMinutes = projects.reduce((sum, p) => sum + p.minutes, 0);
295
+ // Find the longest project name for alignment
296
+ const maxNameLen = Math.max(...projects.map((p) => p.field.length), "Total".length);
297
+ const lines = ["Today's coding time by project:", ""];
298
+ for (const p of projects) {
299
+ const name = p.field.padEnd(maxNameLen + 2);
300
+ lines.push(` ${name}${formatMinutes(p.minutes)}`);
301
+ }
302
+ lines.push(` ${"─".repeat(maxNameLen + 2 + 8)}`);
303
+ lines.push(` ${"Total".padEnd(maxNameLen + 2)}${formatMinutes(totalMinutes)}`);
304
+ return lines.join("\n");
305
+ }
306
+ // Project-specific mode
307
+ const projectName = args.project === "current" ? _projectName : args.project;
308
+ if (projectName) {
309
+ // Fetch both project-specific and total in parallel
310
+ const [projectMins, totalMins] = await Promise.all([
311
+ getProjectMinutes(_token, projectName),
312
+ getTodayMinutes(_token),
313
+ ]);
314
+ if (projectMins === null) {
315
+ return `Failed to fetch coding time for project "${projectName}" from CodeTime API.`;
316
+ }
317
+ const projectFormatted = formatMinutes(projectMins);
318
+ const displayName = args.project === "current"
319
+ ? _projectName
320
+ : projectName;
321
+ if (totalMins !== null) {
322
+ const totalFormatted = formatMinutes(totalMins);
323
+ return `Today's coding time for ${displayName}: ${projectFormatted} (Total across all projects: ${totalFormatted})`;
324
+ }
325
+ return `Today's coding time for ${displayName}: ${projectFormatted}`;
326
+ }
327
+ // Default: total coding time (original behavior)
310
328
  const minutes = await getTodayMinutes(_token);
311
329
  if (minutes === null) {
312
330
  return "Failed to fetch coding time from CodeTime API.";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codetime",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "CodeTime plugin for OpenCode - Track AI coding activity and time spent with codetime.dev",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",