codesprint-mcp-server 0.0.0-stage → 1.0.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,24 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CodeSprint Contributors
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.
22
+
23
+ Third-party notice: the interface icons in media/icons (except codesprint.svg)
24
+ are from Lucide (https://lucide.dev), licensed under the ISC License.
package/README.md CHANGED
@@ -1,3 +1,31 @@
1
- # Temporary Holding Version
1
+ # codesprint-mcp-server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The engine behind the [CodeSprint extension for Zed](https://github.com/harshitsingh205/codesprint-zed): a zero-dependency [Model Context Protocol](https://modelcontextprotocol.io) server over stdio that provides a Pomodoro timer, daily goal, streaks and statistics.
4
+
5
+ It works with any MCP client:
6
+
7
+ ```
8
+ npx codesprint-mcp-server
9
+ ```
10
+
11
+ | Tool | Purpose |
12
+ | --- | --- |
13
+ | `codesprint_status` | Timer state, time left, daily goal progress (read-only) |
14
+ | `codesprint_start` / `pause` / `resume` / `reset` | Control the timer |
15
+ | `codesprint_switch_mode` | `focus`, `shortBreak` or `longBreak` |
16
+ | `codesprint_stats` | Today, week, streaks, 7-day chart (read-only) |
17
+ | `codesprint_clear_data` | Delete all data; requires `confirm: true` |
18
+
19
+ Prompts: `status`, `stats`.
20
+
21
+ ## Configuration
22
+
23
+ Settings are JSON in the `CODESPRINT_SETTINGS` environment variable (the Zed extension fills this from `settings.json`). Invalid or missing values fall back to defaults. `CODESPRINT_DATA_DIR` overrides where `data.json` is stored.
24
+
25
+ ## Development
26
+
27
+ ```
28
+ npm install
29
+ npm test # compile + 58 unit tests
30
+ npm run build # emits dist/
31
+ ```
package/dist/app.js ADDED
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SERVER_VERSION = void 0;
4
+ exports.parseSettings = parseSettings;
5
+ exports.createServer = createServer;
6
+ const format_1 = require("./core/format");
7
+ const settings_1 = require("./core/settings");
8
+ const stats_1 = require("./core/stats");
9
+ const time_1 = require("./core/time");
10
+ const types_1 = require("./core/types");
11
+ const types_2 = require("./core/types");
12
+ const protocol_1 = require("./mcp/protocol");
13
+ exports.SERVER_VERSION = '1.0.0';
14
+ function parseSettings(raw) {
15
+ if (!raw) {
16
+ return { ...settings_1.DEFAULT_SETTINGS };
17
+ }
18
+ try {
19
+ const parsed = JSON.parse(raw);
20
+ return (0, settings_1.normalizeSettings)(typeof parsed === 'object' && parsed !== null ? parsed : {});
21
+ }
22
+ catch {
23
+ return { ...settings_1.DEFAULT_SETTINGS };
24
+ }
25
+ }
26
+ function createServer(service, getSettings, clock = types_2.SYSTEM_CLOCK, write) {
27
+ const server = new protocol_1.McpServer({
28
+ name: 'codesprint',
29
+ title: 'CodeSprint',
30
+ version: exports.SERVER_VERSION,
31
+ instructions: 'CodeSprint is a Pomodoro focus timer with daily goals, streaks and statistics. ' +
32
+ 'Use the codesprint_* tools when the user wants to start, pause, resume or reset a focus session, ' +
33
+ 'switch to a break, or see their progress. Only completed sessions are counted.',
34
+ }, write);
35
+ const status = async (prefix) => {
36
+ const { timer, completionText } = await service.snapshot();
37
+ const body = (0, format_1.formatStatus)(timer, getSettings(), service.history(), clock.now());
38
+ return [prefix, completionText, body].filter(Boolean).join('\n\n');
39
+ };
40
+ const act = (label, noop, run) => async () => {
41
+ const result = await run();
42
+ return status(result.changed ? label : noop);
43
+ };
44
+ const noArgs = { type: 'object', properties: {}, additionalProperties: false };
45
+ server.addTool({
46
+ name: 'codesprint_status',
47
+ title: 'Timer status',
48
+ description: 'Show the current timer state, time remaining and progress toward the daily goal.',
49
+ inputSchema: noArgs,
50
+ annotations: { readOnlyHint: true, openWorldHint: false },
51
+ handler: () => status(),
52
+ });
53
+ server.addTool({
54
+ name: 'codesprint_start',
55
+ title: 'Start timer',
56
+ description: 'Start a session of the current type (focus by default), or resume it if it is paused.',
57
+ inputSchema: noArgs,
58
+ annotations: { idempotentHint: true, openWorldHint: false },
59
+ handler: act('Started.', 'The timer is already running.', () => service.start()),
60
+ });
61
+ server.addTool({
62
+ name: 'codesprint_pause',
63
+ title: 'Pause timer',
64
+ description: 'Pause the running session. Remaining time is kept.',
65
+ inputSchema: noArgs,
66
+ annotations: { idempotentHint: true, openWorldHint: false },
67
+ handler: act('Paused.', 'Nothing to pause: the timer is not running.', () => service.pause()),
68
+ });
69
+ server.addTool({
70
+ name: 'codesprint_resume',
71
+ title: 'Resume timer',
72
+ description: 'Resume a paused session.',
73
+ inputSchema: noArgs,
74
+ annotations: { idempotentHint: true, openWorldHint: false },
75
+ handler: act('Resumed.', 'Nothing to resume: the timer is not paused.', () => service.resume()),
76
+ });
77
+ server.addTool({
78
+ name: 'codesprint_reset',
79
+ title: 'Reset timer',
80
+ description: 'Discard the session in progress and return to idle. Unfinished sessions are never counted.',
81
+ inputSchema: noArgs,
82
+ annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: false },
83
+ handler: act('Session discarded.', 'Nothing to reset: the timer is idle.', () => service.reset()),
84
+ });
85
+ server.addTool({
86
+ name: 'codesprint_switch_mode',
87
+ title: 'Switch session type',
88
+ description: 'Switch between focus, shortBreak and longBreak. A session in progress is discarded, so confirm with the user first if one is running.',
89
+ inputSchema: {
90
+ type: 'object',
91
+ properties: { mode: { type: 'string', enum: [...types_1.SESSION_MODES], description: 'The session type to switch to.' } },
92
+ required: ['mode'],
93
+ additionalProperties: false,
94
+ },
95
+ annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: false },
96
+ handler: async (args) => {
97
+ const mode = args.mode;
98
+ if (!types_1.SESSION_MODES.includes(mode)) {
99
+ throw new Error(`mode must be one of: ${types_1.SESSION_MODES.join(', ')}.`);
100
+ }
101
+ const result = await service.setMode(mode);
102
+ return status(result.changed ? `Switched to ${format_1.MODE_LABELS[mode]}.` : `Already on ${format_1.MODE_LABELS[mode]}.`);
103
+ },
104
+ });
105
+ const statsText = async () => {
106
+ const goalMinutes = getSettings().dailyGoalMinutes;
107
+ await service.snapshot();
108
+ return (0, format_1.formatStats)((0, stats_1.computeStats)(service.history(), clock.now(), goalMinutes * time_1.MINUTE_MS), goalMinutes);
109
+ };
110
+ server.addTool({
111
+ name: 'codesprint_stats',
112
+ title: 'Productivity statistics',
113
+ description: 'Show today, weekly totals, completed sessions, current and longest streak and a 7-day overview.',
114
+ inputSchema: noArgs,
115
+ annotations: { readOnlyHint: true, openWorldHint: false },
116
+ handler: statsText,
117
+ });
118
+ server.addTool({
119
+ name: 'codesprint_clear_data',
120
+ title: 'Clear stored data',
121
+ description: 'Permanently delete all stored sessions, streaks and timer state. Irreversible: only call after the user has explicitly confirmed.',
122
+ inputSchema: {
123
+ type: 'object',
124
+ properties: { confirm: { type: 'boolean', description: 'Must be true to proceed.' } },
125
+ required: ['confirm'],
126
+ additionalProperties: false,
127
+ },
128
+ annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: false },
129
+ handler: async (args) => {
130
+ if (args.confirm !== true) {
131
+ throw new Error('Refusing to delete data without confirm: true.');
132
+ }
133
+ await service.clearAllData();
134
+ return 'All CodeSprint data has been cleared.';
135
+ },
136
+ });
137
+ server.addPrompt({
138
+ name: 'status',
139
+ title: 'Timer status',
140
+ description: 'Insert the current CodeSprint timer status into the conversation.',
141
+ handler: () => status(),
142
+ });
143
+ server.addPrompt({
144
+ name: 'stats',
145
+ title: 'Productivity statistics',
146
+ description: 'Insert your CodeSprint streaks and weekly statistics into the conversation.',
147
+ handler: statsText,
148
+ });
149
+ return server;
150
+ }
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MODE_LABELS = void 0;
4
+ exports.progressBar = progressBar;
5
+ exports.formatStatus = formatStatus;
6
+ exports.formatStats = formatStats;
7
+ const messages_1 = require("./messages");
8
+ const stats_1 = require("./stats");
9
+ const time_1 = require("./time");
10
+ exports.MODE_LABELS = {
11
+ focus: 'Focus',
12
+ shortBreak: 'Short break',
13
+ longBreak: 'Long break',
14
+ };
15
+ const STATUS_LABELS = { idle: 'Ready', running: 'Running', paused: 'Paused' };
16
+ function progressBar(percent, width = 20) {
17
+ const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width);
18
+ return `[${'#'.repeat(filled)}${'-'.repeat(width - filled)}] ${Math.round(percent)}%`;
19
+ }
20
+ function formatStatus(timer, settings, history, now) {
21
+ const stats = (0, stats_1.computeStats)(history, now, settings.dailyGoalMinutes * time_1.MINUTE_MS);
22
+ const lines = [
23
+ `Timer: ${exports.MODE_LABELS[timer.mode]} - ${STATUS_LABELS[timer.status]}`,
24
+ `Time ${timer.status === 'idle' ? 'planned' : 'remaining'}: ${(0, time_1.formatClock)(timer.remainingMs)}`,
25
+ `Cycle: ${timer.cycleDone} of ${timer.cycleLength} focus sessions before a long break`,
26
+ `Today: ${(0, time_1.formatDuration)(stats.todayFocusMs)} of ${(0, time_1.formatDuration)(settings.dailyGoalMinutes * time_1.MINUTE_MS)} ${progressBar(stats.goalPercent)}`,
27
+ ];
28
+ if (timer.endsAt !== null) {
29
+ lines.splice(2, 0, `Ends at: ${new Date(timer.endsAt).toLocaleTimeString()}`);
30
+ }
31
+ const key = (0, time_1.dayKey)(now);
32
+ lines.push('', timer.mode !== 'focus' && timer.status === 'running' ? (0, messages_1.breakMessage)(key) : (0, messages_1.dailyMessage)(key));
33
+ return lines.join('\n');
34
+ }
35
+ function formatStats(stats, goalMinutes) {
36
+ const goalMs = goalMinutes * time_1.MINUTE_MS;
37
+ const rows = stats.week.map((d) => {
38
+ const bar = '#'.repeat(Math.round((d.focusMs / stats.weekMaxMs) * 20)).padEnd(20, '.');
39
+ const label = `${d.label} ${d.key.slice(5)}${d.isToday ? '*' : ' '}`;
40
+ return ` ${label} ${bar} ${(0, time_1.formatDuration)(d.focusMs)}`;
41
+ });
42
+ return [
43
+ `Today: ${(0, time_1.formatDuration)(stats.todayFocusMs)} of ${(0, time_1.formatDuration)(goalMs)} ${progressBar(stats.goalPercent)}`,
44
+ `Daily goal: ${stats.goalMet ? 'reached' : 'not yet reached'}`,
45
+ `This week: ${(0, time_1.formatDuration)(stats.weekFocusMs)} (${stats.weekActiveDays} active days)`,
46
+ `Completed sessions: ${stats.totalSessions}`,
47
+ `Current streak: ${stats.currentStreak} ${stats.currentStreak === 1 ? 'day' : 'days'}`,
48
+ `Longest streak: ${stats.longestStreak} ${stats.longestStreak === 1 ? 'day' : 'days'}`,
49
+ `Active days: ${stats.activeDays}`,
50
+ '',
51
+ 'Last 7 days (* = today):',
52
+ ...rows,
53
+ ].join('\n');
54
+ }
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_DAYS = exports.MAX_RECENT_SESSIONS = void 0;
4
+ exports.emptyHistory = emptyHistory;
5
+ exports.sanitizeHistory = sanitizeHistory;
6
+ exports.addSession = addSession;
7
+ const types_1 = require("./types");
8
+ const time_1 = require("./time");
9
+ exports.MAX_RECENT_SESSIONS = 200;
10
+ exports.MAX_DAYS = 1100;
11
+ const MAX_REASONABLE_MS = 24 * 60 * 60 * 1000;
12
+ function emptyHistory() {
13
+ return { version: 1, totalSessions: 0, days: {}, recent: [] };
14
+ }
15
+ const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
16
+ const isNonNegInt = (v) => typeof v === 'number' && Number.isInteger(v) && v >= 0;
17
+ function sanitizeSession(raw) {
18
+ if (!isRecord(raw)) {
19
+ return undefined;
20
+ }
21
+ const { id, mode, plannedMs, startedAt, endedAt } = raw;
22
+ if (typeof id !== 'string' ||
23
+ id.length === 0 ||
24
+ id.length > 64 ||
25
+ !types_1.SESSION_MODES.includes(mode) ||
26
+ typeof plannedMs !== 'number' ||
27
+ !Number.isFinite(plannedMs) ||
28
+ plannedMs <= 0 ||
29
+ plannedMs > MAX_REASONABLE_MS ||
30
+ typeof startedAt !== 'number' ||
31
+ !Number.isFinite(startedAt) ||
32
+ typeof endedAt !== 'number' ||
33
+ !Number.isFinite(endedAt)) {
34
+ return undefined;
35
+ }
36
+ return { id, mode: mode, plannedMs, startedAt, endedAt };
37
+ }
38
+ function sanitizeHistory(raw) {
39
+ const history = emptyHistory();
40
+ if (!isRecord(raw)) {
41
+ return history;
42
+ }
43
+ if (isRecord(raw.days)) {
44
+ for (const [key, value] of Object.entries(raw.days)) {
45
+ if (!(0, time_1.isDayKey)(key) || !isRecord(value)) {
46
+ continue;
47
+ }
48
+ const { focusMs, sessions } = value;
49
+ if (typeof focusMs === 'number' &&
50
+ Number.isFinite(focusMs) &&
51
+ focusMs >= 0 &&
52
+ isNonNegInt(sessions)) {
53
+ history.days[key] = { focusMs, sessions };
54
+ }
55
+ }
56
+ }
57
+ if (Array.isArray(raw.recent)) {
58
+ for (const item of raw.recent) {
59
+ const session = sanitizeSession(item);
60
+ if (session) {
61
+ history.recent.push(session);
62
+ }
63
+ }
64
+ history.recent = history.recent.slice(-exports.MAX_RECENT_SESSIONS);
65
+ }
66
+ const sumDays = Object.values(history.days).reduce((acc, d) => acc + d.sessions, 0);
67
+ history.totalSessions = isNonNegInt(raw.totalSessions) ? Math.max(raw.totalSessions, sumDays) : sumDays;
68
+ return history;
69
+ }
70
+ function addSession(history, session) {
71
+ if (history.recent.some((s) => s.id === session.id)) {
72
+ return false;
73
+ }
74
+ history.recent.push(session);
75
+ if (history.recent.length > exports.MAX_RECENT_SESSIONS) {
76
+ history.recent.splice(0, history.recent.length - exports.MAX_RECENT_SESSIONS);
77
+ }
78
+ if (session.mode === 'focus') {
79
+ const key = (0, time_1.dayKey)(session.endedAt);
80
+ const day = history.days[key] ?? { focusMs: 0, sessions: 0 };
81
+ day.focusMs += session.plannedMs;
82
+ day.sessions += 1;
83
+ history.days[key] = day;
84
+ history.totalSessions += 1;
85
+ const keys = Object.keys(history.days);
86
+ if (keys.length > exports.MAX_DAYS) {
87
+ keys.sort();
88
+ for (const old of keys.slice(0, keys.length - exports.MAX_DAYS)) {
89
+ delete history.days[old];
90
+ }
91
+ }
92
+ }
93
+ return true;
94
+ }
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BREAK_MESSAGES = exports.GOAL_REACHED_MESSAGE = exports.COMPLETION_MESSAGES = exports.DAILY_MESSAGES = void 0;
4
+ exports.dailyMessage = dailyMessage;
5
+ exports.breakMessage = breakMessage;
6
+ exports.completionMessage = completionMessage;
7
+ exports.DAILY_MESSAGES = [
8
+ 'Every expert was once a beginner.',
9
+ 'Consistency creates progress.',
10
+ 'Focus on improvement, not perfection.',
11
+ 'Every problem solved makes you better.',
12
+ 'Small sessions add up to real skill.',
13
+ 'Show up today and the results will follow.',
14
+ 'Clear code starts with a clear mind.',
15
+ 'Progress is built one focused session at a time.',
16
+ 'Debugging is how you learn how things really work.',
17
+ 'Start with the next small step.',
18
+ 'Steady practice beats occasional intensity.',
19
+ 'You do not need to be perfect to move forward.',
20
+ ];
21
+ exports.COMPLETION_MESSAGES = [
22
+ 'Session complete. Steady work like this builds real skill.',
23
+ 'Well done. Consistency creates progress.',
24
+ 'Another focused session logged. Keep the momentum.',
25
+ 'Good work. Every problem solved makes you better.',
26
+ 'Focus session finished. Small wins compound.',
27
+ 'Nice work. Rest, then continue.',
28
+ ];
29
+ exports.GOAL_REACHED_MESSAGE = 'Daily goal reached. Excellent consistency today.';
30
+ exports.BREAK_MESSAGES = [
31
+ 'Look away from the screen and rest your eyes.',
32
+ 'Stand up, stretch and drink some water.',
33
+ 'Rest is part of the work.',
34
+ ];
35
+ function hash(text) {
36
+ let h = 0;
37
+ for (let i = 0; i < text.length; i++) {
38
+ h = (h * 31 + text.charCodeAt(i)) >>> 0;
39
+ }
40
+ return h;
41
+ }
42
+ function dailyMessage(dayKeyValue) {
43
+ return exports.DAILY_MESSAGES[hash(dayKeyValue) % exports.DAILY_MESSAGES.length];
44
+ }
45
+ function breakMessage(dayKeyValue) {
46
+ return exports.BREAK_MESSAGES[hash(dayKeyValue) % exports.BREAK_MESSAGES.length];
47
+ }
48
+ function completionMessage(sessionsToday) {
49
+ const index = Math.max(0, sessionsToday - 1) % exports.COMPLETION_MESSAGES.length;
50
+ return exports.COMPLETION_MESSAGES[index];
51
+ }
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NullNotifier = exports.DesktopNotifier = void 0;
4
+ const node_child_process_1 = require("node:child_process");
5
+ class DesktopNotifier {
6
+ platform;
7
+ constructor(platform = process.platform) {
8
+ this.platform = platform;
9
+ }
10
+ notify(title, body) {
11
+ try {
12
+ const [command, args, env] = this.buildCommand(title, body);
13
+ const child = (0, node_child_process_1.spawn)(command, args, {
14
+ stdio: 'ignore',
15
+ windowsHide: true,
16
+ detached: false,
17
+ env: { ...process.env, ...env },
18
+ });
19
+ child.on('error', () => undefined);
20
+ child.unref();
21
+ }
22
+ catch {
23
+ }
24
+ }
25
+ buildCommand(title, body) {
26
+ switch (this.platform) {
27
+ case 'darwin': {
28
+ const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
29
+ return ['osascript', ['-e', `display notification "${esc(body)}" with title "${esc(title)}"`], {}];
30
+ }
31
+ case 'win32': {
32
+ const script = 'Add-Type -AssemblyName System.Windows.Forms;' +
33
+ '$n = New-Object System.Windows.Forms.NotifyIcon;' +
34
+ '$n.Icon = [System.Drawing.SystemIcons]::Information;' +
35
+ '$n.Visible = $true;' +
36
+ '$n.ShowBalloonTip(8000, $env:CS_TITLE, $env:CS_BODY, [System.Windows.Forms.ToolTipIcon]::None);' +
37
+ 'Start-Sleep -Seconds 9; $n.Dispose()';
38
+ return ['powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { CS_TITLE: title, CS_BODY: body }];
39
+ }
40
+ default:
41
+ return ['notify-send', ['--app-name=CodeSprint', '--', title, body], {}];
42
+ }
43
+ }
44
+ }
45
+ exports.DesktopNotifier = DesktopNotifier;
46
+ class NullNotifier {
47
+ notify() {
48
+ }
49
+ }
50
+ exports.NullNotifier = NullNotifier;
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SETTING_KEYS = exports.SETTING_SPECS = exports.DEFAULT_SETTINGS = void 0;
4
+ exports.isSettingKey = isSettingKey;
5
+ exports.validateSettingValue = validateSettingValue;
6
+ exports.normalizeSettings = normalizeSettings;
7
+ exports.toEngineConfig = toEngineConfig;
8
+ const time_1 = require("./time");
9
+ exports.DEFAULT_SETTINGS = {
10
+ focusMinutes: 25,
11
+ shortBreakMinutes: 5,
12
+ longBreakMinutes: 15,
13
+ dailyGoalMinutes: 120,
14
+ notifications: true,
15
+ autoBreakSuggestions: true,
16
+ sessionsBeforeLongBreak: 4,
17
+ };
18
+ exports.SETTING_SPECS = {
19
+ focusMinutes: { kind: 'int', min: 1, max: 180 },
20
+ shortBreakMinutes: { kind: 'int', min: 1, max: 60 },
21
+ longBreakMinutes: { kind: 'int', min: 1, max: 120 },
22
+ dailyGoalMinutes: { kind: 'int', min: 5, max: 1440 },
23
+ notifications: { kind: 'boolean' },
24
+ autoBreakSuggestions: { kind: 'boolean' },
25
+ sessionsBeforeLongBreak: { kind: 'int', min: 2, max: 10 },
26
+ };
27
+ exports.SETTING_KEYS = Object.keys(exports.SETTING_SPECS);
28
+ function isSettingKey(key) {
29
+ return typeof key === 'string' && Object.prototype.hasOwnProperty.call(exports.SETTING_SPECS, key);
30
+ }
31
+ function validateSettingValue(key, value) {
32
+ if (!isSettingKey(key)) {
33
+ return undefined;
34
+ }
35
+ const spec = exports.SETTING_SPECS[key];
36
+ switch (spec.kind) {
37
+ case 'enum':
38
+ return typeof value === 'string' && spec.values.includes(value) ? value : undefined;
39
+ case 'int':
40
+ return typeof value === 'number' && Number.isInteger(value) && value >= spec.min && value <= spec.max
41
+ ? value
42
+ : undefined;
43
+ case 'boolean':
44
+ return typeof value === 'boolean' ? value : undefined;
45
+ }
46
+ }
47
+ function normalizeSettings(raw) {
48
+ const out = { ...exports.DEFAULT_SETTINGS };
49
+ for (const key of exports.SETTING_KEYS) {
50
+ const spec = exports.SETTING_SPECS[key];
51
+ const value = raw[key];
52
+ if (spec.kind === 'int' && typeof value === 'number' && Number.isFinite(value)) {
53
+ out[key] = Math.min(spec.max, Math.max(spec.min, Math.round(value)));
54
+ }
55
+ else if (spec.kind === 'enum' && typeof value === 'string' && spec.values.includes(value)) {
56
+ out[key] = value;
57
+ }
58
+ else if (spec.kind === 'boolean' && typeof value === 'boolean') {
59
+ out[key] = value;
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+ function toEngineConfig(s) {
65
+ return {
66
+ durations: {
67
+ focusMs: s.focusMinutes * time_1.MINUTE_MS,
68
+ shortBreakMs: s.shortBreakMinutes * time_1.MINUTE_MS,
69
+ longBreakMs: s.longBreakMinutes * time_1.MINUTE_MS,
70
+ },
71
+ autoBreakSuggestions: s.autoBreakSuggestions,
72
+ sessionsBeforeLongBreak: s.sessionsBeforeLongBreak,
73
+ };
74
+ }
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.computeStreaks = computeStreaks;
4
+ exports.computeStats = computeStats;
5
+ const time_1 = require("./time");
6
+ const SHORT = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
7
+ const FULL = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
8
+ const isActive = (d) => !!d && d.sessions > 0;
9
+ function runEndingAt(days, endKey) {
10
+ let count = 0;
11
+ let key = endKey;
12
+ while (isActive(days[key])) {
13
+ count += 1;
14
+ key = (0, time_1.shiftDayKey)(key, -1);
15
+ }
16
+ return count;
17
+ }
18
+ function computeStreaks(days, todayKey) {
19
+ const activeKeys = Object.keys(days)
20
+ .filter((k) => isActive(days[k]))
21
+ .sort();
22
+ let longest = 0;
23
+ let run = 0;
24
+ let previous;
25
+ for (const key of activeKeys) {
26
+ run = previous !== undefined && (0, time_1.shiftDayKey)(previous, 1) === key ? run + 1 : 1;
27
+ longest = Math.max(longest, run);
28
+ previous = key;
29
+ }
30
+ const current = isActive(days[todayKey])
31
+ ? runEndingAt(days, todayKey)
32
+ : runEndingAt(days, (0, time_1.shiftDayKey)(todayKey, -1));
33
+ return { current, longest, activeDays: activeKeys.length };
34
+ }
35
+ function computeStats(history, now, goalMs) {
36
+ const todayKey = (0, time_1.dayKey)(now);
37
+ const days = history.days;
38
+ const today = days[todayKey]?.focusMs ?? 0;
39
+ const week = [];
40
+ for (let i = 6; i >= 0; i--) {
41
+ const key = (0, time_1.shiftDayKey)(todayKey, -i);
42
+ const record = days[key];
43
+ const dow = (0, time_1.parseDayKey)(key).getDay();
44
+ week.push({
45
+ key,
46
+ label: SHORT[dow],
47
+ fullLabel: FULL[dow],
48
+ focusMs: record?.focusMs ?? 0,
49
+ sessions: record?.sessions ?? 0,
50
+ active: isActive(record),
51
+ isToday: i === 0,
52
+ });
53
+ }
54
+ const streaks = computeStreaks(days, todayKey);
55
+ const goalPercent = goalMs > 0 ? Math.min(100, Math.round((today / goalMs) * 100)) : 0;
56
+ return {
57
+ todayFocusMs: today,
58
+ totalSessions: history.totalSessions,
59
+ weekFocusMs: week.reduce((sum, d) => sum + d.focusMs, 0),
60
+ goalPercent,
61
+ goalMet: goalMs > 0 && today >= goalMs,
62
+ currentStreak: streaks.current,
63
+ longestStreak: streaks.longest,
64
+ activeDays: streaks.activeDays,
65
+ week,
66
+ weekActiveDays: week.filter((d) => d.active).length,
67
+ weekMaxMs: Math.max(goalMs, ...week.map((d) => d.focusMs), 1),
68
+ };
69
+ }
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FileStore = exports.Store = exports.STORAGE_KEYS = void 0;
4
+ exports.defaultDataDir = defaultDataDir;
5
+ const fs = require("node:fs");
6
+ const path = require("node:path");
7
+ const timerState_1 = require("./timerState");
8
+ const history_1 = require("./history");
9
+ exports.STORAGE_KEYS = {
10
+ timer: 'codesprint.timer.v1',
11
+ history: 'codesprint.history.v1',
12
+ };
13
+ class Store {
14
+ kv;
15
+ constructor(kv) {
16
+ this.kv = kv;
17
+ }
18
+ read(key) {
19
+ try {
20
+ return this.kv.get(key);
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ loadTimerState(config, now) {
27
+ return (0, timerState_1.sanitizeTimerState)(this.read(exports.STORAGE_KEYS.timer), now, config);
28
+ }
29
+ saveTimerState(state) {
30
+ return this.kv.update(exports.STORAGE_KEYS.timer, state);
31
+ }
32
+ loadHistory() {
33
+ return (0, history_1.sanitizeHistory)(this.read(exports.STORAGE_KEYS.history));
34
+ }
35
+ async recordSession(session) {
36
+ const history = this.loadHistory();
37
+ if (!(0, history_1.addSession)(history, session)) {
38
+ return false;
39
+ }
40
+ await this.kv.update(exports.STORAGE_KEYS.history, history);
41
+ return true;
42
+ }
43
+ async clearAll() {
44
+ await this.kv.update(exports.STORAGE_KEYS.timer, undefined);
45
+ await this.kv.update(exports.STORAGE_KEYS.history, (0, history_1.emptyHistory)());
46
+ }
47
+ }
48
+ exports.Store = Store;
49
+ class FileStore {
50
+ file;
51
+ constructor(file) {
52
+ this.file = file;
53
+ }
54
+ readAll() {
55
+ try {
56
+ const parsed = JSON.parse(fs.readFileSync(this.file, 'utf8'));
57
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
58
+ ? parsed
59
+ : {};
60
+ }
61
+ catch {
62
+ return {};
63
+ }
64
+ }
65
+ get(key) {
66
+ return this.readAll()[key];
67
+ }
68
+ update(key, value) {
69
+ const data = this.readAll();
70
+ if (value === undefined) {
71
+ delete data[key];
72
+ }
73
+ else {
74
+ data[key] = value;
75
+ }
76
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
77
+ const tmp = `${this.file}.${process.pid}.tmp`;
78
+ fs.writeFileSync(tmp, JSON.stringify(data), { encoding: 'utf8', mode: 0o600 });
79
+ fs.renameSync(tmp, this.file);
80
+ return Promise.resolve();
81
+ }
82
+ }
83
+ exports.FileStore = FileStore;
84
+ function defaultDataDir(env = process.env, platform = process.platform) {
85
+ if (env.CODESPRINT_DATA_DIR) {
86
+ return env.CODESPRINT_DATA_DIR;
87
+ }
88
+ const home = env.HOME ?? env.USERPROFILE ?? '.';
89
+ if (platform === 'win32') {
90
+ return path.join(env.APPDATA ?? path.join(home, 'AppData', 'Roaming'), 'CodeSprint');
91
+ }
92
+ if (platform === 'darwin') {
93
+ return path.join(home, 'Library', 'Application Support', 'CodeSprint');
94
+ }
95
+ return path.join(env.XDG_DATA_HOME ?? path.join(home, '.local', 'share'), 'codesprint');
96
+ }
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MINUTE_MS = void 0;
4
+ exports.dayKey = dayKey;
5
+ exports.parseDayKey = parseDayKey;
6
+ exports.isDayKey = isDayKey;
7
+ exports.shiftDayKey = shiftDayKey;
8
+ exports.formatClock = formatClock;
9
+ exports.formatDuration = formatDuration;
10
+ exports.greetingForHour = greetingForHour;
11
+ exports.MINUTE_MS = 60_000;
12
+ const pad = (n) => String(n).padStart(2, '0');
13
+ function dayKey(ts) {
14
+ const d = new Date(ts);
15
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
16
+ }
17
+ function parseDayKey(key) {
18
+ const [y, m, d] = key.split('-').map(Number);
19
+ return new Date(y, m - 1, d, 12, 0, 0, 0);
20
+ }
21
+ function isDayKey(value) {
22
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
23
+ return false;
24
+ }
25
+ return dayKey(parseDayKey(value).getTime()) === value;
26
+ }
27
+ function shiftDayKey(key, deltaDays) {
28
+ const d = parseDayKey(key);
29
+ d.setDate(d.getDate() + deltaDays);
30
+ return dayKey(d.getTime());
31
+ }
32
+ function formatClock(ms) {
33
+ const total = Math.max(0, Math.ceil(ms / 1000));
34
+ return `${pad(Math.floor(total / 60))}:${pad(total % 60)}`;
35
+ }
36
+ function formatDuration(ms) {
37
+ const minutes = Math.max(0, Math.round(ms / exports.MINUTE_MS));
38
+ const h = Math.floor(minutes / 60);
39
+ const m = minutes % 60;
40
+ if (h > 0 && m > 0) {
41
+ return `${h}h ${m}m`;
42
+ }
43
+ return h > 0 ? `${h}h` : `${m}m`;
44
+ }
45
+ function greetingForHour(hour) {
46
+ if (hour < 12) {
47
+ return 'Good morning';
48
+ }
49
+ return hour < 18 ? 'Good afternoon' : 'Good evening';
50
+ }
@@ -0,0 +1,176 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TimerEngine = exports.MAX_DURATION_MS = exports.MIN_DURATION_MS = void 0;
4
+ exports.durationFor = durationFor;
5
+ exports.createIdleState = createIdleState;
6
+ exports.MIN_DURATION_MS = 1_000;
7
+ exports.MAX_DURATION_MS = 24 * 60 * 60 * 1000;
8
+ function durationFor(mode, d) {
9
+ switch (mode) {
10
+ case 'focus':
11
+ return d.focusMs;
12
+ case 'shortBreak':
13
+ return d.shortBreakMs;
14
+ case 'longBreak':
15
+ return d.longBreakMs;
16
+ }
17
+ }
18
+ function createIdleState(mode, config, focusSinceLongBreak = 0) {
19
+ return {
20
+ mode,
21
+ status: 'idle',
22
+ durationMs: durationFor(mode, config.durations),
23
+ elapsedMs: 0,
24
+ segmentStartedAt: null,
25
+ sessionStartedAt: null,
26
+ focusSinceLongBreak,
27
+ };
28
+ }
29
+ class TimerEngine {
30
+ getConfig;
31
+ clock;
32
+ state;
33
+ constructor(getConfig, clock, initial) {
34
+ this.getConfig = getConfig;
35
+ this.clock = clock;
36
+ this.state = initial ? { ...initial } : createIdleState('focus', getConfig());
37
+ }
38
+ getState() {
39
+ return { ...this.state };
40
+ }
41
+ get status() {
42
+ return this.state.status;
43
+ }
44
+ get mode() {
45
+ return this.state.mode;
46
+ }
47
+ elapsedMs(now = this.clock.now()) {
48
+ const s = this.state;
49
+ if (s.status === 'idle') {
50
+ return 0;
51
+ }
52
+ if (s.status === 'running' && s.segmentStartedAt !== null) {
53
+ const segment = Math.max(0, now - s.segmentStartedAt);
54
+ return Math.min(s.durationMs, s.elapsedMs + segment);
55
+ }
56
+ return s.elapsedMs;
57
+ }
58
+ remainingMs(now = this.clock.now()) {
59
+ if (this.state.status === 'idle') {
60
+ return this.state.durationMs;
61
+ }
62
+ return Math.max(0, this.state.durationMs - this.elapsedMs(now));
63
+ }
64
+ snapshot(now = this.clock.now()) {
65
+ const s = this.state;
66
+ const cfg = this.getConfig();
67
+ const remainingMs = this.remainingMs(now);
68
+ return {
69
+ mode: s.mode,
70
+ status: s.status,
71
+ durationMs: s.durationMs,
72
+ remainingMs,
73
+ endsAt: s.status === 'running' ? now + remainingMs : null,
74
+ cycleDone: Math.min(s.focusSinceLongBreak, cfg.sessionsBeforeLongBreak),
75
+ cycleLength: cfg.sessionsBeforeLongBreak,
76
+ };
77
+ }
78
+ start() {
79
+ const s = this.state;
80
+ if (s.status === 'running') {
81
+ return false;
82
+ }
83
+ if (s.status === 'paused') {
84
+ return this.resume();
85
+ }
86
+ const now = this.clock.now();
87
+ this.state = {
88
+ ...s,
89
+ status: 'running',
90
+ durationMs: durationFor(s.mode, this.getConfig().durations),
91
+ elapsedMs: 0,
92
+ segmentStartedAt: now,
93
+ sessionStartedAt: now,
94
+ };
95
+ return true;
96
+ }
97
+ pause() {
98
+ const s = this.state;
99
+ if (s.status !== 'running') {
100
+ return false;
101
+ }
102
+ const now = this.clock.now();
103
+ if (this.remainingMs(now) <= 0) {
104
+ return false;
105
+ }
106
+ this.state = { ...s, status: 'paused', elapsedMs: this.elapsedMs(now), segmentStartedAt: null };
107
+ return true;
108
+ }
109
+ resume() {
110
+ const s = this.state;
111
+ if (s.status !== 'paused') {
112
+ return false;
113
+ }
114
+ this.state = { ...s, status: 'running', segmentStartedAt: this.clock.now() };
115
+ return true;
116
+ }
117
+ reset() {
118
+ const s = this.state;
119
+ if (s.status === 'idle') {
120
+ return false;
121
+ }
122
+ this.state = createIdleState(s.mode, this.getConfig(), s.focusSinceLongBreak);
123
+ return true;
124
+ }
125
+ setMode(mode) {
126
+ this.state = createIdleState(mode, this.getConfig(), this.state.focusSinceLongBreak);
127
+ }
128
+ restart() {
129
+ this.state = createIdleState('focus', this.getConfig(), 0);
130
+ }
131
+ syncDurations() {
132
+ const s = this.state;
133
+ if (s.status !== 'idle') {
134
+ return false;
135
+ }
136
+ const durationMs = durationFor(s.mode, this.getConfig().durations);
137
+ if (durationMs === s.durationMs) {
138
+ return false;
139
+ }
140
+ this.state = { ...s, durationMs };
141
+ return true;
142
+ }
143
+ checkCompletion() {
144
+ const s = this.state;
145
+ if (s.status !== 'running' || s.segmentStartedAt === null) {
146
+ return undefined;
147
+ }
148
+ if (this.remainingMs() > 0) {
149
+ return undefined;
150
+ }
151
+ const endedAt = s.segmentStartedAt + (s.durationMs - s.elapsedMs);
152
+ const startedAt = s.sessionStartedAt ?? s.segmentStartedAt;
153
+ const session = {
154
+ id: `${startedAt}-${s.mode}`,
155
+ mode: s.mode,
156
+ plannedMs: s.durationMs,
157
+ startedAt,
158
+ endedAt,
159
+ };
160
+ const cfg = this.getConfig();
161
+ let nextMode = 'focus';
162
+ let focusSinceLongBreak = s.focusSinceLongBreak;
163
+ if (s.mode === 'focus') {
164
+ focusSinceLongBreak += 1;
165
+ if (cfg.autoBreakSuggestions) {
166
+ nextMode = focusSinceLongBreak >= cfg.sessionsBeforeLongBreak ? 'longBreak' : 'shortBreak';
167
+ }
168
+ }
169
+ else if (s.mode === 'longBreak') {
170
+ focusSinceLongBreak = 0;
171
+ }
172
+ this.state = createIdleState(nextMode, cfg, focusSinceLongBreak);
173
+ return { session, nextMode };
174
+ }
175
+ }
176
+ exports.TimerEngine = TimerEngine;
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TimerService = void 0;
4
+ const format_1 = require("./format");
5
+ const messages_1 = require("./messages");
6
+ const settings_1 = require("./settings");
7
+ const timerEngine_1 = require("./timerEngine");
8
+ const types_1 = require("./types");
9
+ const time_1 = require("./time");
10
+ class TimerService {
11
+ store;
12
+ getSettings;
13
+ notifier;
14
+ clock;
15
+ wake;
16
+ constructor(store, getSettings, notifier, clock = types_1.SYSTEM_CLOCK) {
17
+ this.store = store;
18
+ this.getSettings = getSettings;
19
+ this.notifier = notifier;
20
+ this.clock = clock;
21
+ }
22
+ engine() {
23
+ const getConfig = () => (0, settings_1.toEngineConfig)(this.getSettings());
24
+ return new timerEngine_1.TimerEngine(getConfig, this.clock, this.store.loadTimerState(getConfig(), this.clock.now()));
25
+ }
26
+ async finalize(engine) {
27
+ const completion = engine.checkCompletion();
28
+ if (!completion) {
29
+ return undefined;
30
+ }
31
+ await this.store.recordSession(completion.session);
32
+ await this.store.saveTimerState(engine.getState());
33
+ const text = this.completionTextFor(completion);
34
+ this.notifyCompletion(completion, text);
35
+ return text;
36
+ }
37
+ completionTextFor(completion) {
38
+ const { session } = completion;
39
+ if (session.mode !== 'focus') {
40
+ return undefined;
41
+ }
42
+ const today = this.store.loadHistory().days[(0, time_1.dayKey)(session.endedAt)];
43
+ const goalMs = this.getSettings().dailyGoalMinutes * time_1.MINUTE_MS;
44
+ const justReached = !!today && today.focusMs >= goalMs && today.focusMs - session.plannedMs < goalMs;
45
+ return justReached ? messages_1.GOAL_REACHED_MESSAGE : (0, messages_1.completionMessage)(today?.sessions ?? 1);
46
+ }
47
+ notifyCompletion(completion, text) {
48
+ if (!this.getSettings().notifications) {
49
+ return;
50
+ }
51
+ const { session, nextMode } = completion;
52
+ const minutes = Math.max(1, Math.round(session.plannedMs / time_1.MINUTE_MS));
53
+ if (session.mode === 'focus') {
54
+ const next = nextMode === 'focus' ? '' : ` Next: ${format_1.MODE_LABELS[nextMode]}.`;
55
+ this.notifier.notify('CodeSprint - focus session complete', `${minutes} min logged.${next} ${text ?? ''}`.trim());
56
+ }
57
+ else {
58
+ this.notifier.notify(`CodeSprint - ${format_1.MODE_LABELS[session.mode]} finished`, 'Ready for the next focus session?');
59
+ }
60
+ }
61
+ schedule(engine) {
62
+ if (this.wake) {
63
+ clearTimeout(this.wake);
64
+ this.wake = undefined;
65
+ }
66
+ if (engine.status !== 'running') {
67
+ return;
68
+ }
69
+ const delay = Math.min(engine.remainingMs() + 250, 2 ** 31 - 1);
70
+ this.wake = setTimeout(() => void this.tick(), delay);
71
+ this.wake.unref();
72
+ }
73
+ async tick() {
74
+ const engine = this.engine();
75
+ await this.finalize(engine);
76
+ this.schedule(engine);
77
+ }
78
+ async initialize() {
79
+ const engine = this.engine();
80
+ await this.finalize(engine);
81
+ this.schedule(engine);
82
+ }
83
+ async snapshot() {
84
+ const engine = this.engine();
85
+ const completionText = await this.finalize(engine);
86
+ this.schedule(engine);
87
+ return { timer: engine.snapshot(), completionText };
88
+ }
89
+ async run(action, reschedule = true) {
90
+ const engine = this.engine();
91
+ const completionText = await this.finalize(engine);
92
+ const changed = action(engine);
93
+ if (changed) {
94
+ await this.store.saveTimerState(engine.getState());
95
+ }
96
+ if (reschedule) {
97
+ this.schedule(engine);
98
+ }
99
+ return { changed, completionText };
100
+ }
101
+ start() {
102
+ return this.run((e) => e.start());
103
+ }
104
+ pause() {
105
+ return this.run((e) => e.pause());
106
+ }
107
+ resume() {
108
+ return this.run((e) => e.resume());
109
+ }
110
+ reset() {
111
+ return this.run((e) => e.reset());
112
+ }
113
+ setMode(mode) {
114
+ if (!types_1.SESSION_MODES.includes(mode)) {
115
+ return Promise.resolve({ changed: false });
116
+ }
117
+ return this.run((e) => {
118
+ if (e.mode === mode) {
119
+ return false;
120
+ }
121
+ e.setMode(mode);
122
+ return true;
123
+ });
124
+ }
125
+ async clearAllData() {
126
+ await this.store.clearAll();
127
+ if (this.wake) {
128
+ clearTimeout(this.wake);
129
+ this.wake = undefined;
130
+ }
131
+ }
132
+ history() {
133
+ return this.store.loadHistory();
134
+ }
135
+ dispose() {
136
+ if (this.wake) {
137
+ clearTimeout(this.wake);
138
+ }
139
+ }
140
+ }
141
+ exports.TimerService = TimerService;
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sanitizeTimerState = sanitizeTimerState;
4
+ const timerEngine_1 = require("./timerEngine");
5
+ const types_1 = require("./types");
6
+ const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
7
+ const isFiniteNumber = (v) => typeof v === 'number' && Number.isFinite(v);
8
+ function sanitizeTimerState(raw, now, config) {
9
+ if (!isRecord(raw)) {
10
+ return undefined;
11
+ }
12
+ const { mode, status, durationMs, elapsedMs, segmentStartedAt, sessionStartedAt, focusSinceLongBreak } = raw;
13
+ if (!types_1.SESSION_MODES.includes(mode) || !types_1.TIMER_STATUSES.includes(status)) {
14
+ return undefined;
15
+ }
16
+ const safeMode = mode;
17
+ const safeStatus = status;
18
+ const cycle = typeof focusSinceLongBreak === 'number' && Number.isInteger(focusSinceLongBreak)
19
+ ? Math.min(Math.max(focusSinceLongBreak, 0), 1000)
20
+ : 0;
21
+ if (safeStatus === 'idle') {
22
+ return (0, timerEngine_1.createIdleState)(safeMode, config, cycle);
23
+ }
24
+ if (!isFiniteNumber(durationMs) || durationMs < timerEngine_1.MIN_DURATION_MS || durationMs > timerEngine_1.MAX_DURATION_MS) {
25
+ return undefined;
26
+ }
27
+ if (!isFiniteNumber(elapsedMs) || elapsedMs < 0 || elapsedMs > durationMs) {
28
+ return undefined;
29
+ }
30
+ let segment = null;
31
+ if (safeStatus === 'running') {
32
+ if (!isFiniteNumber(segmentStartedAt) || segmentStartedAt <= 0) {
33
+ return undefined;
34
+ }
35
+ segment = Math.min(segmentStartedAt, now);
36
+ }
37
+ const started = isFiniteNumber(sessionStartedAt) && sessionStartedAt > 0 ? Math.min(sessionStartedAt, now) : segment ?? now;
38
+ return {
39
+ mode: safeMode,
40
+ status: safeStatus,
41
+ durationMs,
42
+ elapsedMs,
43
+ segmentStartedAt: segment,
44
+ sessionStartedAt: started,
45
+ focusSinceLongBreak: cycle,
46
+ };
47
+ }
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SYSTEM_CLOCK = exports.TIMER_STATUSES = exports.SESSION_MODES = void 0;
4
+ exports.SESSION_MODES = ['focus', 'shortBreak', 'longBreak'];
5
+ exports.TIMER_STATUSES = ['idle', 'running', 'paused'];
6
+ exports.SYSTEM_CLOCK = { now: () => Date.now() };
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const path = require("node:path");
5
+ const app_1 = require("./app");
6
+ const notifier_1 = require("./core/notifier");
7
+ const storage_1 = require("./core/storage");
8
+ const timerService_1 = require("./core/timerService");
9
+ async function main() {
10
+ const settings = (0, app_1.parseSettings)(process.env.CODESPRINT_SETTINGS);
11
+ const store = new storage_1.Store(new storage_1.FileStore(path.join((0, storage_1.defaultDataDir)(), 'data.json')));
12
+ const service = new timerService_1.TimerService(store, () => settings, new notifier_1.DesktopNotifier());
13
+ await service.initialize();
14
+ const server = (0, app_1.createServer)(service, () => settings);
15
+ await server.listen();
16
+ service.dispose();
17
+ }
18
+ main().catch((e) => {
19
+ process.stderr.write(`[codesprint] fatal: ${e.stack ?? String(e)}\n`);
20
+ process.exit(1);
21
+ });
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.McpServer = exports.SUPPORTED_PROTOCOL_VERSIONS = void 0;
4
+ const readline = require("node:readline");
5
+ exports.SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
6
+ const ERR = { parse: -32700, invalidRequest: -32600, notFound: -32601, invalidParams: -32602, internal: -32603 };
7
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
8
+ class McpServer {
9
+ info;
10
+ write;
11
+ tools = new Map();
12
+ prompts = new Map();
13
+ constructor(info, write = (line) => process.stdout.write(line + '\n')) {
14
+ this.info = info;
15
+ this.write = write;
16
+ }
17
+ addTool(tool) {
18
+ this.tools.set(tool.name, tool);
19
+ }
20
+ addPrompt(prompt) {
21
+ this.prompts.set(prompt.name, prompt);
22
+ }
23
+ listen(input = process.stdin) {
24
+ const rl = readline.createInterface({ input, crlfDelay: Infinity });
25
+ rl.on('line', (line) => {
26
+ if (line.trim()) {
27
+ void this.handleLine(line);
28
+ }
29
+ });
30
+ return new Promise((resolve) => rl.on('close', resolve));
31
+ }
32
+ async handleLine(line) {
33
+ let message;
34
+ try {
35
+ message = JSON.parse(line);
36
+ }
37
+ catch {
38
+ this.error(null, ERR.parse, 'Parse error');
39
+ return;
40
+ }
41
+ if (Array.isArray(message)) {
42
+ for (const item of message) {
43
+ await this.handleMessage(item);
44
+ }
45
+ return;
46
+ }
47
+ await this.handleMessage(message);
48
+ }
49
+ async handleMessage(message) {
50
+ if (!isObject(message) || message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
51
+ if (!isObject(message) || !('result' in message || 'error' in message)) {
52
+ this.error(isObject(message) && 'id' in message ? message.id : null, ERR.invalidRequest, 'Invalid request');
53
+ }
54
+ return;
55
+ }
56
+ const request = message;
57
+ const isNotification = !('id' in request) || request.id === undefined;
58
+ try {
59
+ const result = await this.dispatch(request);
60
+ if (!isNotification) {
61
+ this.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }));
62
+ }
63
+ }
64
+ catch (e) {
65
+ if (isNotification) {
66
+ return;
67
+ }
68
+ if (e instanceof RpcError) {
69
+ this.error(request.id ?? null, e.code, e.message);
70
+ }
71
+ else {
72
+ process.stderr.write(`[codesprint] ${e.stack ?? String(e)}\n`);
73
+ this.error(request.id ?? null, ERR.internal, 'Internal error');
74
+ }
75
+ }
76
+ }
77
+ async dispatch(req) {
78
+ const params = req.params ?? {};
79
+ switch (req.method) {
80
+ case 'initialize': {
81
+ const requested = typeof params.protocolVersion === 'string' ? params.protocolVersion : '';
82
+ const version = exports.SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
83
+ ? requested
84
+ : exports.SUPPORTED_PROTOCOL_VERSIONS[0];
85
+ return {
86
+ protocolVersion: version,
87
+ capabilities: { tools: { listChanged: false }, prompts: { listChanged: false } },
88
+ serverInfo: { name: this.info.name, title: this.info.title, version: this.info.version },
89
+ ...(this.info.instructions ? { instructions: this.info.instructions } : {}),
90
+ };
91
+ }
92
+ case 'ping':
93
+ return {};
94
+ case 'notifications/initialized':
95
+ case 'notifications/cancelled':
96
+ return undefined;
97
+ case 'tools/list':
98
+ return {
99
+ tools: [...this.tools.values()].map(({ name, title, description, inputSchema, annotations }) => ({
100
+ name, title, description, inputSchema, annotations,
101
+ })),
102
+ };
103
+ case 'tools/call':
104
+ return this.callTool(params);
105
+ case 'prompts/list':
106
+ return { prompts: [...this.prompts.values()].map(({ name, title, description }) => ({ name, title, description })) };
107
+ case 'prompts/get':
108
+ return this.getPrompt(params);
109
+ default:
110
+ throw new RpcError(ERR.notFound, `Method not found: ${req.method}`);
111
+ }
112
+ }
113
+ async callTool(params) {
114
+ const tool = typeof params.name === 'string' ? this.tools.get(params.name) : undefined;
115
+ if (!tool) {
116
+ throw new RpcError(ERR.invalidParams, `Unknown tool: ${String(params.name)}`);
117
+ }
118
+ const args = isObject(params.arguments) ? params.arguments : {};
119
+ try {
120
+ const text = await tool.handler(args);
121
+ return { content: [{ type: 'text', text }] };
122
+ }
123
+ catch (e) {
124
+ return { content: [{ type: 'text', text: e.message }], isError: true };
125
+ }
126
+ }
127
+ async getPrompt(params) {
128
+ const prompt = typeof params.name === 'string' ? this.prompts.get(params.name) : undefined;
129
+ if (!prompt) {
130
+ throw new RpcError(ERR.invalidParams, `Unknown prompt: ${String(params.name)}`);
131
+ }
132
+ const text = await prompt.handler();
133
+ return { description: prompt.description, messages: [{ role: 'user', content: { type: 'text', text } }] };
134
+ }
135
+ error(id, code, message) {
136
+ this.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }));
137
+ }
138
+ }
139
+ exports.McpServer = McpServer;
140
+ class RpcError extends Error {
141
+ code;
142
+ constructor(code, message) {
143
+ super(message);
144
+ this.code = code;
145
+ }
146
+ }
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "codesprint-mcp-server",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "CodeSprint focus timer, daily goals, streaks and stats as a Model Context Protocol server for the Zed editor.",
5
+ "license": "MIT",
6
+ "author": "CodeSprint Contributors <harshitkishoresingh26@gmail.com>",
7
+ "repository": { "type": "git", "url": "git+https://github.com/harshitsingh205/codesprint-zed.git", "directory": "server" },
8
+ "bugs": { "url": "https://github.com/harshitsingh205/codesprint-zed/issues" },
9
+ "homepage": "https://github.com/harshitsingh205/codesprint-zed#readme",
10
+ "keywords": ["zed", "mcp", "model-context-protocol", "pomodoro", "focus", "timer", "productivity"],
11
+ "bin": { "codesprint-mcp-server": "dist/index.js" },
12
+ "main": "dist/index.js",
13
+ "files": ["dist", "README.md", "LICENSE"],
14
+ "engines": { "node": ">=18" },
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.build.json",
17
+ "typecheck": "tsc -p tsconfig.json --noEmit",
18
+ "test": "tsc -p tsconfig.json && node scripts/run-tests.js",
19
+ "prepublishOnly": "npm run test && npm run build"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^20.19.0",
23
+ "typescript": "^5.9.3"
24
+ }
25
+ }