opencode-codetime 0.1.0 → 0.3.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 +10 -1
- package/dist/index.js +98 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
# opencode-codetime
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/opencode-codetime)
|
|
4
|
+
[](https://www.npmjs.com/package/opencode-codetime)
|
|
5
|
+
[](https://github.com/roman-pinchuk/opencode-codetime/blob/main/LICENSE)
|
|
6
|
+
[](https://github.com/roman-pinchuk/opencode-codetime/releases)
|
|
7
|
+
[](https://github.com/roman-pinchuk/opencode-codetime/actions)
|
|
8
|
+
|
|
3
9
|
[CodeTime](https://codetime.dev) plugin for [OpenCode](https://github.com/anomalyco/opencode) -- track your AI coding activity and time spent.
|
|
4
10
|
|
|
5
11
|
## Features
|
|
6
12
|
|
|
7
13
|
- **Automatic time tracking** -- sends coding events to CodeTime when OpenCode reads, edits, or writes files
|
|
14
|
+
- **Check your coding time** -- ask the AI "what's my coding time?" and it fetches your stats via the `codetime` tool
|
|
8
15
|
- **Language detection** -- detects 90+ programming languages from file extensions
|
|
9
16
|
- **Git integration** -- captures current branch and remote origin
|
|
17
|
+
- **Project identification** -- shows as `[opencode] project-name` on your CodeTime dashboard
|
|
10
18
|
- **Rate-limited** -- one heartbeat per 2 minutes to avoid API spam
|
|
11
19
|
- **Session lifecycle** -- flushes pending events on session end so no data is lost
|
|
12
20
|
- **Zero config** -- just set your token and go
|
|
@@ -81,7 +89,7 @@ Each heartbeat sent to CodeTime includes:
|
|
|
81
89
|
|-------|-------|
|
|
82
90
|
| `eventTime` | Unix timestamp of the event |
|
|
83
91
|
| `language` | Detected from file extension (e.g. `TypeScript`, `Python`) |
|
|
84
|
-
| `project` |
|
|
92
|
+
| `project` | `[opencode] directory-name` |
|
|
85
93
|
| `relativeFile` | File path relative to the project root |
|
|
86
94
|
| `editor` | `opencode` |
|
|
87
95
|
| `platform` | OS platform (`darwin`, `linux`, `windows`) |
|
|
@@ -94,6 +102,7 @@ Each heartbeat sent to CodeTime includes:
|
|
|
94
102
|
|------|---------|
|
|
95
103
|
| `event` | Listens for `message.part.updated` (tool completions) and `session.idle`/`session.deleted` (flush) |
|
|
96
104
|
| `chat.message` | Processes pending heartbeats on chat activity |
|
|
105
|
+
| `tool` | Registers `codetime` tool to check today's coding time |
|
|
97
106
|
|
|
98
107
|
### Tool tracking
|
|
99
108
|
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as os from "node:os";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { tool } from "@opencode-ai/plugin/tool";
|
|
4
|
+
import { sendHeartbeat, validateToken, getTodayMinutes, } from "./codetime.js";
|
|
4
5
|
import { getGitBranch, getGitOrigin } from "./git.js";
|
|
5
6
|
import { detectLanguage } from "./language.js";
|
|
6
7
|
import { initLogger, info, warn, error, debug } from "./logger.js";
|
|
@@ -9,11 +10,14 @@ import { initState, shouldSendHeartbeat, updateLastHeartbeat, } from "./state.js
|
|
|
9
10
|
const processedCallIds = new Set();
|
|
10
11
|
const pendingFiles = new Map();
|
|
11
12
|
let _token = null;
|
|
13
|
+
let _client = null;
|
|
12
14
|
let _projectName = "unknown";
|
|
13
15
|
let _projectDir = "";
|
|
14
16
|
let _worktree = "";
|
|
17
|
+
let _shellFn = null;
|
|
15
18
|
let _gitOrigin = null;
|
|
16
19
|
let _gitBranch = null;
|
|
20
|
+
let _gitInfoFetched = false;
|
|
17
21
|
let _platform = os.platform();
|
|
18
22
|
// ---- File extraction from tool events ----
|
|
19
23
|
function extractFilesFromTool(tool, metadata, output, title) {
|
|
@@ -80,6 +84,20 @@ function computeRelativeFile(absoluteFile, projectDir) {
|
|
|
80
84
|
}
|
|
81
85
|
return path.basename(absoluteFile);
|
|
82
86
|
}
|
|
87
|
+
// ---- Lazy git info ----
|
|
88
|
+
async function ensureGitInfo() {
|
|
89
|
+
if (_gitInfoFetched || !_shellFn || !_worktree)
|
|
90
|
+
return;
|
|
91
|
+
_gitInfoFetched = true;
|
|
92
|
+
try {
|
|
93
|
+
_gitOrigin = await getGitOrigin(_shellFn, _worktree);
|
|
94
|
+
_gitBranch = await getGitBranch(_shellFn, _worktree);
|
|
95
|
+
await debug("Git info", { origin: _gitOrigin, branch: _gitBranch }).catch(() => { });
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Git info is optional, continue without it
|
|
99
|
+
}
|
|
100
|
+
}
|
|
83
101
|
// ---- Heartbeat processing ----
|
|
84
102
|
async function processHeartbeats(force = false) {
|
|
85
103
|
if (!_token)
|
|
@@ -88,6 +106,7 @@ async function processHeartbeats(force = false) {
|
|
|
88
106
|
return;
|
|
89
107
|
if (pendingFiles.size === 0)
|
|
90
108
|
return;
|
|
109
|
+
await ensureGitInfo();
|
|
91
110
|
const promises = [];
|
|
92
111
|
for (const [filePath, fileInfo] of pendingFiles.entries()) {
|
|
93
112
|
const relativeFile = computeRelativeFile(filePath, _projectDir);
|
|
@@ -134,6 +153,18 @@ function pruneProcessedIds() {
|
|
|
134
153
|
}
|
|
135
154
|
}
|
|
136
155
|
}
|
|
156
|
+
// ---- Time formatting ----
|
|
157
|
+
function formatMinutes(minutes) {
|
|
158
|
+
if (minutes < 1)
|
|
159
|
+
return "0m";
|
|
160
|
+
const hours = Math.floor(minutes / 60);
|
|
161
|
+
const mins = Math.round(minutes % 60);
|
|
162
|
+
if (hours === 0)
|
|
163
|
+
return `${mins}m`;
|
|
164
|
+
if (mins === 0)
|
|
165
|
+
return `${hours}h`;
|
|
166
|
+
return `${hours}h ${mins}m`;
|
|
167
|
+
}
|
|
137
168
|
// ---- Plugin entry point ----
|
|
138
169
|
export const plugin = async (ctx) => {
|
|
139
170
|
try {
|
|
@@ -164,17 +195,13 @@ export const plugin = async (ctx) => {
|
|
|
164
195
|
}).catch(() => { });
|
|
165
196
|
// Initialize state
|
|
166
197
|
initState();
|
|
198
|
+
_client = client;
|
|
167
199
|
_projectDir = directory;
|
|
168
200
|
_worktree = worktree;
|
|
169
|
-
_projectName = path.basename(directory)
|
|
201
|
+
_projectName = `${path.basename(directory)} [opencode]`;
|
|
170
202
|
_platform = os.platform();
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
const shellFn = $;
|
|
174
|
-
_gitOrigin = await getGitOrigin(shellFn, worktree);
|
|
175
|
-
_gitBranch = await getGitBranch(shellFn, worktree);
|
|
176
|
-
await debug("Git info", { origin: _gitOrigin, branch: _gitBranch }).catch(() => { });
|
|
177
|
-
}
|
|
203
|
+
// Store shell function for lazy git info fetching
|
|
204
|
+
_shellFn = $;
|
|
178
205
|
return {
|
|
179
206
|
event: async ({ event }) => {
|
|
180
207
|
try {
|
|
@@ -231,6 +258,68 @@ export const plugin = async (ctx) => {
|
|
|
231
258
|
await error("Chat message handler error", { error: String(err) }).catch(() => { });
|
|
232
259
|
}
|
|
233
260
|
},
|
|
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
|
+
}
|
|
298
|
+
},
|
|
299
|
+
tool: {
|
|
300
|
+
codetime: tool({
|
|
301
|
+
description: "Show today's coding time tracked by CodeTime. " +
|
|
302
|
+
"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() {
|
|
306
|
+
if (!_token) {
|
|
307
|
+
return "CodeTime is not configured. Set CODETIME_TOKEN environment variable to enable tracking. Get your token from https://codetime.dev/dashboard/settings";
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const minutes = await getTodayMinutes(_token);
|
|
311
|
+
if (minutes === null) {
|
|
312
|
+
return "Failed to fetch coding time from CodeTime API.";
|
|
313
|
+
}
|
|
314
|
+
const formatted = formatMinutes(minutes);
|
|
315
|
+
return `Today's coding time: ${formatted}`;
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
return `Failed to fetch coding time: ${String(err)}`;
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
}),
|
|
322
|
+
},
|
|
234
323
|
};
|
|
235
324
|
}
|
|
236
325
|
catch (err) {
|