opencode-codetime 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Roman Pinchuk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,155 @@
1
+ # opencode-codetime
2
+
3
+ [CodeTime](https://codetime.dev) plugin for [OpenCode](https://github.com/anomalyco/opencode) -- track your AI coding activity and time spent.
4
+
5
+ ## Features
6
+
7
+ - **Automatic time tracking** -- sends coding events to CodeTime when OpenCode reads, edits, or writes files
8
+ - **Language detection** -- detects 90+ programming languages from file extensions
9
+ - **Git integration** -- captures current branch and remote origin
10
+ - **Rate-limited** -- one heartbeat per 2 minutes to avoid API spam
11
+ - **Session lifecycle** -- flushes pending events on session end so no data is lost
12
+ - **Zero config** -- just set your token and go
13
+
14
+ ## Prerequisites
15
+
16
+ A [CodeTime](https://codetime.dev) account. Sign up for free at [codetime.dev](https://codetime.dev).
17
+
18
+ Get your upload token from [CodeTime Settings](https://codetime.dev/dashboard/settings).
19
+
20
+ ## Installation
21
+
22
+ ### Via opencode config (recommended)
23
+
24
+ Add to your `opencode.json`:
25
+
26
+ ```json
27
+ {
28
+ "$schema": "https://opencode.ai/config.json",
29
+ "plugin": ["opencode-codetime"]
30
+ }
31
+ ```
32
+
33
+ ### From source
34
+
35
+ ```bash
36
+ git clone https://github.com/roman-pinchuk/opencode-codetime
37
+ cd opencode-codetime
38
+ npm install && npm run build
39
+ ```
40
+
41
+ Then copy the built files to your OpenCode plugins directory:
42
+
43
+ ```bash
44
+ mkdir -p ~/.config/opencode/plugins
45
+ cp dist/*.js ~/.config/opencode/plugins/
46
+ ```
47
+
48
+ ## Configuration
49
+
50
+ Set your CodeTime upload token as an environment variable:
51
+
52
+ ```bash
53
+ export CODETIME_TOKEN="your-upload-token-here"
54
+ ```
55
+
56
+ Add it to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.) to persist across sessions.
57
+
58
+ The plugin validates the token on startup and logs a warning if it is missing or invalid.
59
+
60
+ ## How It Works
61
+
62
+ The plugin hooks into OpenCode's event system to detect file activity.
63
+
64
+ ```
65
+ OpenCode events Plugin CodeTime API
66
+ +-----------------------+ +------------------+ +------------------------+
67
+ | message.part.updated | --> | Extract files | --> | POST /v3/users/event-log|
68
+ | (edit, write, read, | | Detect language | | |
69
+ | patch, multiedit) | | Rate limit (2m) | | { eventTime, language, |
70
+ +-----------------------+ +------------------+ | project, relativeFile,|
71
+ | session.idle | --> | Force flush | | editor: "opencode", |
72
+ | session.deleted | | pending events | | platform, git info } |
73
+ +-----------------------+ +------------------+ +------------------------+
74
+ ```
75
+
76
+ ### What gets tracked
77
+
78
+ Each heartbeat sent to CodeTime includes:
79
+
80
+ | Field | Value |
81
+ |-------|-------|
82
+ | `eventTime` | Unix timestamp of the event |
83
+ | `language` | Detected from file extension (e.g. `TypeScript`, `Python`) |
84
+ | `project` | Current directory name |
85
+ | `relativeFile` | File path relative to the project root |
86
+ | `editor` | `opencode` |
87
+ | `platform` | OS platform (`darwin`, `linux`, `windows`) |
88
+ | `gitOrigin` | Remote origin URL (if available) |
89
+ | `gitBranch` | Current branch name (if available) |
90
+
91
+ ### Hooks used
92
+
93
+ | Hook | Purpose |
94
+ |------|---------|
95
+ | `event` | Listens for `message.part.updated` (tool completions) and `session.idle`/`session.deleted` (flush) |
96
+ | `chat.message` | Processes pending heartbeats on chat activity |
97
+
98
+ ### Tool tracking
99
+
100
+ | Tool | Data Extracted |
101
+ |------|---------------|
102
+ | `read` | File path from title |
103
+ | `edit` | File path from filediff metadata |
104
+ | `write` | File path from metadata |
105
+ | `patch` | File paths from output |
106
+ | `multiedit` | File paths from each edit result |
107
+
108
+ ## Project Structure
109
+
110
+ ```
111
+ src/
112
+ index.ts Main plugin entry point with event hooks
113
+ codetime.ts CodeTime API client (token validation, heartbeats)
114
+ state.ts Rate limiter (max 1 heartbeat per 2 minutes)
115
+ language.ts File extension to language name mapping
116
+ git.ts Git branch and remote origin extraction
117
+ logger.ts Structured logging via OpenCode SDK
118
+ ```
119
+
120
+ ## Development
121
+
122
+ ```bash
123
+ # Install dependencies
124
+ npm install
125
+
126
+ # Type check
127
+ npm run typecheck
128
+
129
+ # Build
130
+ npm run build
131
+ ```
132
+
133
+ ## Troubleshooting
134
+
135
+ ### Plugin not loading
136
+
137
+ 1. Verify `opencode.json` syntax
138
+ 2. Check that `CODETIME_TOKEN` is set in your environment
139
+ 3. Check OpenCode logs for `opencode-codetime` messages
140
+
141
+ ### Heartbeats not appearing in CodeTime
142
+
143
+ 1. Verify your token at [codetime.dev/dashboard/settings](https://codetime.dev/dashboard/settings)
144
+ 2. Make sure you are editing files (not just chatting) -- heartbeats fire on tool completions
145
+ 3. Wait up to 2 minutes for rate-limited heartbeats to flush
146
+ 4. Check that the CodeTime API is reachable: `curl -s https://api.codetime.dev/v3`
147
+
148
+ ### Token validation fails
149
+
150
+ 1. Regenerate your token at [codetime.dev/dashboard/settings](https://codetime.dev/dashboard/settings)
151
+ 2. Make sure the token has no leading/trailing whitespace
152
+
153
+ ## License
154
+
155
+ [MIT](LICENSE)
@@ -0,0 +1,30 @@
1
+ export interface EventLogRequest {
2
+ eventTime: number;
3
+ language: string;
4
+ project: string;
5
+ relativeFile: string;
6
+ editor: string;
7
+ platform: string;
8
+ absoluteFile?: string | null;
9
+ gitOrigin?: string | null;
10
+ gitBranch?: string | null;
11
+ }
12
+ export interface UserSelf {
13
+ id: number;
14
+ username: string;
15
+ uploadToken: string;
16
+ plan: string;
17
+ }
18
+ /**
19
+ * Validate the CodeTime token by fetching the current user.
20
+ * Returns user info on success, or null on failure.
21
+ */
22
+ export declare function validateToken(token: string): Promise<UserSelf | null>;
23
+ /**
24
+ * Send a coding event (heartbeat) to the CodeTime API.
25
+ */
26
+ export declare function sendHeartbeat(token: string, event: EventLogRequest): Promise<boolean>;
27
+ /**
28
+ * Fetch today's coding minutes from CodeTime API.
29
+ */
30
+ export declare function getTodayMinutes(token: string): Promise<number | null>;
@@ -0,0 +1,85 @@
1
+ import * as logger from "./logger.js";
2
+ const API_BASE = "https://api.codetime.dev";
3
+ /**
4
+ * Validate the CodeTime token by fetching the current user.
5
+ * Returns user info on success, or null on failure.
6
+ */
7
+ export async function validateToken(token) {
8
+ try {
9
+ const response = await fetch(`${API_BASE}/v3/users/self`, {
10
+ method: "GET",
11
+ headers: {
12
+ Authorization: `Bearer ${token}`,
13
+ "Content-Type": "application/json",
14
+ },
15
+ });
16
+ if (!response.ok) {
17
+ await logger.error("Token validation failed", {
18
+ status: response.status,
19
+ statusText: response.statusText,
20
+ });
21
+ return null;
22
+ }
23
+ const data = (await response.json());
24
+ return data;
25
+ }
26
+ catch (err) {
27
+ await logger.error("Token validation request failed", {
28
+ error: String(err),
29
+ });
30
+ return null;
31
+ }
32
+ }
33
+ /**
34
+ * Send a coding event (heartbeat) to the CodeTime API.
35
+ */
36
+ export async function sendHeartbeat(token, event) {
37
+ try {
38
+ const response = await fetch(`${API_BASE}/v3/users/event-log`, {
39
+ method: "POST",
40
+ headers: {
41
+ Authorization: `Bearer ${token}`,
42
+ "Content-Type": "application/json",
43
+ },
44
+ body: JSON.stringify(event),
45
+ });
46
+ if (!response.ok) {
47
+ await logger.warn("Failed to send heartbeat", {
48
+ status: response.status,
49
+ statusText: response.statusText,
50
+ event,
51
+ });
52
+ return false;
53
+ }
54
+ await logger.debug("Heartbeat sent", { event });
55
+ return true;
56
+ }
57
+ catch (err) {
58
+ await logger.error("Heartbeat request failed", {
59
+ error: String(err),
60
+ event,
61
+ });
62
+ return false;
63
+ }
64
+ }
65
+ /**
66
+ * Fetch today's coding minutes from CodeTime API.
67
+ */
68
+ export async function getTodayMinutes(token) {
69
+ try {
70
+ const response = await fetch(`${API_BASE}/v3/users/self/minutes?minutes=1440`, {
71
+ method: "GET",
72
+ headers: {
73
+ Authorization: `Bearer ${token}`,
74
+ "Content-Type": "application/json",
75
+ },
76
+ });
77
+ if (!response.ok)
78
+ return null;
79
+ const data = (await response.json());
80
+ return data.minutes;
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
package/dist/git.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ interface ShellFn {
2
+ (strings: TemplateStringsArray, ...values: unknown[]): Promise<{
3
+ stdout: Buffer;
4
+ exitCode: number;
5
+ }>;
6
+ }
7
+ /**
8
+ * Extract git remote origin URL from the worktree.
9
+ */
10
+ export declare function getGitOrigin($: ShellFn, worktree: string): Promise<string | null>;
11
+ /**
12
+ * Extract the current git branch from the worktree.
13
+ */
14
+ export declare function getGitBranch($: ShellFn, worktree: string): Promise<string | null>;
15
+ export {};
package/dist/git.js ADDED
@@ -0,0 +1,32 @@
1
+ import * as debug from "./logger.js";
2
+ /**
3
+ * Extract git remote origin URL from the worktree.
4
+ */
5
+ export async function getGitOrigin($, worktree) {
6
+ 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
+ }
11
+ }
12
+ catch {
13
+ await debug.debug("Failed to get git origin", { worktree });
14
+ }
15
+ return null;
16
+ }
17
+ /**
18
+ * Extract the current git branch from the worktree.
19
+ */
20
+ export async function getGitBranch($, worktree) {
21
+ 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
+ }
27
+ }
28
+ catch {
29
+ await debug.debug("Failed to get git branch", { worktree });
30
+ }
31
+ return null;
32
+ }
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ export declare const plugin: Plugin;
3
+ export default plugin;
package/dist/index.js ADDED
@@ -0,0 +1,243 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+ import { sendHeartbeat, validateToken, } from "./codetime.js";
4
+ import { getGitBranch, getGitOrigin } from "./git.js";
5
+ import { detectLanguage } from "./language.js";
6
+ import { initLogger, info, warn, error, debug } from "./logger.js";
7
+ import { initState, shouldSendHeartbeat, updateLastHeartbeat, } from "./state.js";
8
+ // ---- State ----
9
+ const processedCallIds = new Set();
10
+ const pendingFiles = new Map();
11
+ let _token = null;
12
+ let _projectName = "unknown";
13
+ let _projectDir = "";
14
+ let _worktree = "";
15
+ let _gitOrigin = null;
16
+ let _gitBranch = null;
17
+ let _platform = os.platform();
18
+ // ---- File extraction from tool events ----
19
+ function extractFilesFromTool(tool, metadata, output, title) {
20
+ const files = [];
21
+ if (!metadata && tool !== "read")
22
+ return files;
23
+ switch (tool) {
24
+ case "edit": {
25
+ const filediff = metadata?.filediff;
26
+ if (filediff?.file) {
27
+ files.push(filediff.file);
28
+ }
29
+ else {
30
+ const filePath = metadata?.filePath;
31
+ if (filePath)
32
+ files.push(filePath);
33
+ }
34
+ break;
35
+ }
36
+ case "write": {
37
+ const filepath = metadata?.filepath;
38
+ if (filepath)
39
+ files.push(filepath);
40
+ break;
41
+ }
42
+ case "read": {
43
+ if (title)
44
+ files.push(title);
45
+ break;
46
+ }
47
+ case "patch": {
48
+ const lines = output.split("\n");
49
+ for (const line of lines) {
50
+ if (line.startsWith(" ") && !line.startsWith(" ")) {
51
+ const file = line.trim();
52
+ if (file && !file.includes(" "))
53
+ files.push(file);
54
+ }
55
+ }
56
+ break;
57
+ }
58
+ case "multiedit": {
59
+ const results = metadata?.results;
60
+ if (results) {
61
+ for (const result of results) {
62
+ if (result.filediff?.file) {
63
+ files.push(result.filediff.file);
64
+ }
65
+ }
66
+ }
67
+ break;
68
+ }
69
+ case "glob":
70
+ case "grep":
71
+ case "bash":
72
+ // These don't produce meaningful file paths for tracking
73
+ break;
74
+ }
75
+ return files;
76
+ }
77
+ function computeRelativeFile(absoluteFile, projectDir) {
78
+ if (absoluteFile.startsWith(projectDir)) {
79
+ return absoluteFile.slice(projectDir.length).replace(/^\//, "");
80
+ }
81
+ return path.basename(absoluteFile);
82
+ }
83
+ // ---- Heartbeat processing ----
84
+ async function processHeartbeats(force = false) {
85
+ if (!_token)
86
+ return;
87
+ if (!shouldSendHeartbeat(force) && !force)
88
+ return;
89
+ if (pendingFiles.size === 0)
90
+ return;
91
+ const promises = [];
92
+ for (const [filePath, fileInfo] of pendingFiles.entries()) {
93
+ const relativeFile = computeRelativeFile(filePath, _projectDir);
94
+ const event = {
95
+ eventTime: Math.floor(Date.now() / 1000),
96
+ language: fileInfo.language,
97
+ project: _projectName,
98
+ relativeFile,
99
+ editor: "opencode",
100
+ platform: _platform,
101
+ absoluteFile: fileInfo.absoluteFile ?? filePath,
102
+ gitOrigin: _gitOrigin,
103
+ gitBranch: _gitBranch,
104
+ };
105
+ const p = sendHeartbeat(_token, event);
106
+ if (force) {
107
+ promises.push(p);
108
+ }
109
+ }
110
+ pendingFiles.clear();
111
+ updateLastHeartbeat();
112
+ if (force && promises.length > 0) {
113
+ await Promise.all(promises);
114
+ }
115
+ }
116
+ function trackFile(filePath) {
117
+ const language = detectLanguage(filePath);
118
+ pendingFiles.set(filePath, {
119
+ language,
120
+ absoluteFile: filePath,
121
+ });
122
+ }
123
+ // ---- Event guard ----
124
+ function isMessagePartUpdatedEvent(event) {
125
+ return event.type === "message.part.updated";
126
+ }
127
+ // ---- Deduplication cleanup ----
128
+ function pruneProcessedIds() {
129
+ if (processedCallIds.size > 1000) {
130
+ const entries = Array.from(processedCallIds);
131
+ const toRemove = entries.slice(0, entries.length - 500);
132
+ for (const id of toRemove) {
133
+ processedCallIds.delete(id);
134
+ }
135
+ }
136
+ }
137
+ // ---- Plugin entry point ----
138
+ export const plugin = async (ctx) => {
139
+ try {
140
+ const { client, directory, worktree, $ } = ctx;
141
+ // Initialize logger (may fail if client shape differs)
142
+ try {
143
+ initLogger(client);
144
+ }
145
+ catch {
146
+ // Logger init failed, continue without structured logging
147
+ }
148
+ // Read token from environment
149
+ _token = process.env.CODETIME_TOKEN ?? null;
150
+ if (!_token) {
151
+ await warn("CODETIME_TOKEN not set. CodeTime tracking disabled. " +
152
+ "Get your token from https://codetime.dev/dashboard/settings").catch(() => { });
153
+ return {};
154
+ }
155
+ // Validate token
156
+ const user = await validateToken(_token);
157
+ if (!user) {
158
+ await error("Invalid CODETIME_TOKEN. Please check your token at https://codetime.dev/dashboard/settings").catch(() => { });
159
+ _token = null;
160
+ return {};
161
+ }
162
+ await info(`CodeTime plugin initialized for user: ${user.username}`, {
163
+ plan: user.plan,
164
+ }).catch(() => { });
165
+ // Initialize state
166
+ initState();
167
+ _projectDir = directory;
168
+ _worktree = worktree;
169
+ _projectName = path.basename(directory);
170
+ _platform = os.platform();
171
+ // Fetch git info
172
+ if (worktree) {
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
+ }
178
+ return {
179
+ event: async ({ event }) => {
180
+ try {
181
+ if (!_token)
182
+ return;
183
+ // Handle tool completions
184
+ if (isMessagePartUpdatedEvent(event)) {
185
+ const { part } = event.properties;
186
+ if (!("tool" in part) || part.type !== "tool")
187
+ return;
188
+ const toolPart = part;
189
+ if (toolPart.state.status !== "completed")
190
+ return;
191
+ // Deduplicate
192
+ if (processedCallIds.has(toolPart.callID))
193
+ return;
194
+ processedCallIds.add(toolPart.callID);
195
+ pruneProcessedIds();
196
+ const { tool, state } = toolPart;
197
+ const metadata = state.metadata;
198
+ const output = state.output ?? "";
199
+ const title = state.title;
200
+ const files = extractFilesFromTool(tool, metadata, output, title);
201
+ if (files.length > 0) {
202
+ for (const file of files) {
203
+ trackFile(file);
204
+ }
205
+ // Try to process heartbeats (rate limiter will throttle if needed)
206
+ await processHeartbeats();
207
+ }
208
+ }
209
+ // Handle session lifecycle - force flush
210
+ if (event.type === "session.idle" ||
211
+ event.type === "session.deleted") {
212
+ await debug("Session ending, flushing heartbeats", {
213
+ type: event.type,
214
+ pendingFiles: pendingFiles.size,
215
+ }).catch(() => { });
216
+ await processHeartbeats(true);
217
+ }
218
+ }
219
+ catch (err) {
220
+ await error("Event handler error", { error: String(err) }).catch(() => { });
221
+ }
222
+ },
223
+ "chat.message": async () => {
224
+ try {
225
+ // On any chat activity, try to process pending heartbeats
226
+ if (_token && pendingFiles.size > 0) {
227
+ await processHeartbeats();
228
+ }
229
+ }
230
+ catch (err) {
231
+ await error("Chat message handler error", { error: String(err) }).catch(() => { });
232
+ }
233
+ },
234
+ };
235
+ }
236
+ catch (err) {
237
+ // If anything goes wrong during plugin initialization,
238
+ // return empty hooks so OpenCode can continue without this plugin
239
+ console.error("[opencode-codetime] Plugin failed to initialize:", err);
240
+ return {};
241
+ }
242
+ };
243
+ export default plugin;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Detect the programming language from a file path using its extension.
3
+ * Returns "Unknown" if the extension is not recognized.
4
+ */
5
+ export declare function detectLanguage(filePath: string): string;
@@ -0,0 +1,121 @@
1
+ import * as path from "node:path";
2
+ const EXTENSION_MAP = {
3
+ // JavaScript / TypeScript
4
+ ".js": "JavaScript",
5
+ ".mjs": "JavaScript",
6
+ ".cjs": "JavaScript",
7
+ ".jsx": "JavaScript JSX",
8
+ ".ts": "TypeScript",
9
+ ".mts": "TypeScript",
10
+ ".cts": "TypeScript",
11
+ ".tsx": "TypeScript JSX",
12
+ // Web
13
+ ".html": "HTML",
14
+ ".htm": "HTML",
15
+ ".css": "CSS",
16
+ ".scss": "SCSS",
17
+ ".sass": "Sass",
18
+ ".less": "Less",
19
+ ".vue": "Vue",
20
+ ".svelte": "Svelte",
21
+ ".astro": "Astro",
22
+ // Python
23
+ ".py": "Python",
24
+ ".pyw": "Python",
25
+ ".pyi": "Python",
26
+ // Go
27
+ ".go": "Go",
28
+ // Rust
29
+ ".rs": "Rust",
30
+ // Java / Kotlin
31
+ ".java": "Java",
32
+ ".kt": "Kotlin",
33
+ ".kts": "Kotlin",
34
+ // C / C++
35
+ ".c": "C",
36
+ ".h": "C",
37
+ ".cpp": "C++",
38
+ ".cc": "C++",
39
+ ".cxx": "C++",
40
+ ".hpp": "C++",
41
+ // C#
42
+ ".cs": "C#",
43
+ // Swift
44
+ ".swift": "Swift",
45
+ // Ruby
46
+ ".rb": "Ruby",
47
+ // PHP
48
+ ".php": "PHP",
49
+ // Lua
50
+ ".lua": "Lua",
51
+ // Shell
52
+ ".sh": "Shell",
53
+ ".bash": "Shell",
54
+ ".zsh": "Shell",
55
+ ".fish": "Shell",
56
+ // Config / Data
57
+ ".json": "JSON",
58
+ ".jsonc": "JSONC",
59
+ ".yaml": "YAML",
60
+ ".yml": "YAML",
61
+ ".toml": "TOML",
62
+ ".xml": "XML",
63
+ ".csv": "CSV",
64
+ // Markup / Docs
65
+ ".md": "Markdown",
66
+ ".mdx": "MDX",
67
+ ".rst": "reStructuredText",
68
+ ".tex": "LaTeX",
69
+ // Docker / DevOps
70
+ ".dockerfile": "Dockerfile",
71
+ ".tf": "Terraform",
72
+ ".hcl": "HCL",
73
+ // SQL
74
+ ".sql": "SQL",
75
+ // R
76
+ ".r": "R",
77
+ ".R": "R",
78
+ // Elixir / Erlang
79
+ ".ex": "Elixir",
80
+ ".exs": "Elixir",
81
+ ".erl": "Erlang",
82
+ // Dart
83
+ ".dart": "Dart",
84
+ // Zig
85
+ ".zig": "Zig",
86
+ // Nim
87
+ ".nim": "Nim",
88
+ // Scala
89
+ ".scala": "Scala",
90
+ // Haskell
91
+ ".hs": "Haskell",
92
+ // OCaml
93
+ ".ml": "OCaml",
94
+ ".mli": "OCaml",
95
+ // Clojure
96
+ ".clj": "Clojure",
97
+ ".cljs": "ClojureScript",
98
+ // GraphQL
99
+ ".graphql": "GraphQL",
100
+ ".gql": "GraphQL",
101
+ // Proto
102
+ ".proto": "Protocol Buffers",
103
+ };
104
+ /**
105
+ * Detect the programming language from a file path using its extension.
106
+ * Returns "Unknown" if the extension is not recognized.
107
+ */
108
+ export function detectLanguage(filePath) {
109
+ if (!filePath)
110
+ return "Unknown";
111
+ // Handle special filenames
112
+ const basename = path.basename(filePath).toLowerCase();
113
+ if (basename === "dockerfile")
114
+ return "Dockerfile";
115
+ if (basename === "makefile" || basename === "gnumakefile")
116
+ return "Makefile";
117
+ if (basename === "cmakelists.txt")
118
+ return "CMake";
119
+ const ext = path.extname(filePath).toLowerCase();
120
+ return EXTENSION_MAP[ext] ?? "Unknown";
121
+ }
@@ -0,0 +1,20 @@
1
+ type LogLevel = "debug" | "info" | "warn" | "error";
2
+ interface LogClient {
3
+ app: {
4
+ log: (opts: {
5
+ body: {
6
+ service: string;
7
+ level: LogLevel;
8
+ message: string;
9
+ extra?: Record<string, unknown>;
10
+ };
11
+ }) => Promise<unknown>;
12
+ };
13
+ }
14
+ export declare function initLogger(client: LogClient): void;
15
+ export declare function log(level: LogLevel, message: string, extra?: Record<string, unknown>): Promise<void>;
16
+ export declare function debug(message: string, extra?: Record<string, unknown>): Promise<void>;
17
+ export declare function info(message: string, extra?: Record<string, unknown>): Promise<void>;
18
+ export declare function warn(message: string, extra?: Record<string, unknown>): Promise<void>;
19
+ export declare function error(message: string, extra?: Record<string, unknown>): Promise<void>;
20
+ export {};
package/dist/logger.js ADDED
@@ -0,0 +1,29 @@
1
+ let _client = null;
2
+ const SERVICE = "opencode-codetime";
3
+ export function initLogger(client) {
4
+ _client = client;
5
+ }
6
+ export async function log(level, message, extra) {
7
+ if (!_client)
8
+ return;
9
+ try {
10
+ await _client.app.log({
11
+ body: { service: SERVICE, level, message, extra },
12
+ });
13
+ }
14
+ catch {
15
+ // Silently ignore logging failures
16
+ }
17
+ }
18
+ export async function debug(message, extra) {
19
+ return log("debug", message, extra);
20
+ }
21
+ export async function info(message, extra) {
22
+ return log("info", message, extra);
23
+ }
24
+ export async function warn(message, extra) {
25
+ return log("warn", message, extra);
26
+ }
27
+ export async function error(message, extra) {
28
+ return log("error", message, extra);
29
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Rate limiter for CodeTime heartbeats.
3
+ * Prevents sending more than one heartbeat per HEARTBEAT_INTERVAL_MS per project.
4
+ */
5
+ /**
6
+ * Initialize state (called on plugin startup).
7
+ */
8
+ export declare function initState(): void;
9
+ /**
10
+ * Check if enough time has passed to send a new heartbeat.
11
+ * @param force If true, always returns true.
12
+ */
13
+ export declare function shouldSendHeartbeat(force?: boolean): boolean;
14
+ /**
15
+ * Record that a heartbeat was just sent.
16
+ */
17
+ export declare function updateLastHeartbeat(): void;
package/dist/state.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Rate limiter for CodeTime heartbeats.
3
+ * Prevents sending more than one heartbeat per HEARTBEAT_INTERVAL_MS per project.
4
+ */
5
+ const HEARTBEAT_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
6
+ let lastHeartbeatTime = 0;
7
+ /**
8
+ * Initialize state (called on plugin startup).
9
+ */
10
+ export function initState() {
11
+ lastHeartbeatTime = 0;
12
+ }
13
+ /**
14
+ * Check if enough time has passed to send a new heartbeat.
15
+ * @param force If true, always returns true.
16
+ */
17
+ export function shouldSendHeartbeat(force = false) {
18
+ if (force)
19
+ return true;
20
+ const now = Date.now();
21
+ return now - lastHeartbeatTime >= HEARTBEAT_INTERVAL_MS;
22
+ }
23
+ /**
24
+ * Record that a heartbeat was just sent.
25
+ */
26
+ export function updateLastHeartbeat() {
27
+ lastHeartbeatTime = Date.now();
28
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "opencode-codetime",
3
+ "version": "0.1.0",
4
+ "description": "CodeTime plugin for OpenCode - Track AI coding activity and time spent with codetime.dev",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "typecheck": "tsc --noEmit",
20
+ "prepublishOnly": "npm run build"
21
+ },
22
+ "keywords": [
23
+ "opencode",
24
+ "codetime",
25
+ "coding-time",
26
+ "time-tracking",
27
+ "opencode-plugin"
28
+ ],
29
+ "author": "Roman Pinchuk",
30
+ "license": "MIT",
31
+ "homepage": "https://github.com/roman-pinchuk/opencode-codetime#readme",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/roman-pinchuk/opencode-codetime.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/roman-pinchuk/opencode-codetime/issues"
38
+ },
39
+ "peerDependencies": {
40
+ "@opencode-ai/plugin": ">=1.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "@opencode-ai/plugin": "^1.0.0",
44
+ "@types/node": "^25.3.3",
45
+ "typescript": "^5.0.0"
46
+ }
47
+ }