codeling 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/.vite/build/main.js +91 -0
- package/LICENSE +21 -0
- package/README.md +237 -0
- package/package.json +77 -0
- package/scripts/codeling-cli.mjs +328 -0
- package/scripts/install-stop-hook.mjs +148 -0
- package/scripts/install-telemetry.ps1 +106 -0
- package/scripts/install-telemetry.sh +104 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";const u=require("electron"),_t=require("menubar"),E=require("node:path"),T=require("node:fs"),yt=require("better-sqlite3"),bt=require("node:events"),Tt=require("node:assert"),St=require("node:os"),vt=require("node:util"),wt=require("@grpc/grpc-js"),Rt=require("@grpc/proto-loader"),kt=require("protobufjs"),te=require("express"),Be=require("node:fs/promises");function We(e){const t=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});if(e){for(const n in e)if(n!=="default"){const s=Object.getOwnPropertyDescriptor(e,n);Object.defineProperty(t,n,s.get?s:{enumerable:!0,get:()=>e[n]})}}return t.default=e,Object.freeze(t)}const ne=We(wt),Lt=We(Rt),Ot=32,Ge=5,qe=1e3,R={wizard:{label:"Wizard",tier:"common",priceBits:200},slime:{label:"Slime",tier:"common",priceBits:200},bat:{label:"Bat",tier:"common",priceBits:200},rat:{label:"Rat",tier:"common",priceBits:200},mushroom:{label:"Mushroom",tier:"common",priceBits:200},skeleton:{label:"Skeleton",tier:"common",priceBits:200},goblin:{label:"Goblin",tier:"common",priceBits:200},flying_eye:{label:"Flying Eye",tier:"uncommon",priceBits:400},fire_worm:{label:"Fire Worm",tier:"uncommon",priceBits:400},mimic:{label:"Mimic",tier:"rare",priceBits:800},evil_wizard:{label:"Evil Wizard",tier:"rare",priceBits:800},apprentice_wizard:{label:"Apprentice Wizard",tier:"rare",priceBits:800},martial_hero:{label:"Martial Hero",tier:"legendary",priceBits:1500},martial_hero_2:{label:"Martial Hero II",tier:"legendary",priceBits:1500}},At=`CREATE TABLE IF NOT EXISTS pet (
|
|
2
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
3
|
+
species TEXT NOT NULL,
|
|
4
|
+
name TEXT NOT NULL,
|
|
5
|
+
evolution_stage INTEGER NOT NULL DEFAULT 0,
|
|
6
|
+
level INTEGER NOT NULL DEFAULT 1,
|
|
7
|
+
xp INTEGER NOT NULL DEFAULT 0,
|
|
8
|
+
bits INTEGER NOT NULL DEFAULT 0,
|
|
9
|
+
created_at INTEGER NOT NULL
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
CREATE TABLE IF NOT EXISTS unlocks (
|
|
13
|
+
item_id TEXT PRIMARY KEY,
|
|
14
|
+
category TEXT NOT NULL,
|
|
15
|
+
acquired_via TEXT NOT NULL,
|
|
16
|
+
acquired_at INTEGER NOT NULL,
|
|
17
|
+
equipped INTEGER NOT NULL DEFAULT 0
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
21
|
+
session_id TEXT PRIMARY KEY,
|
|
22
|
+
started_at INTEGER NOT NULL,
|
|
23
|
+
last_seen_at INTEGER NOT NULL,
|
|
24
|
+
message_count INTEGER NOT NULL DEFAULT 0,
|
|
25
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
26
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
27
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
28
|
+
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
|
29
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
30
|
+
stop_event_count INTEGER NOT NULL DEFAULT 0
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE TABLE IF NOT EXISTS otel_events (
|
|
34
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
35
|
+
signal_type TEXT NOT NULL,
|
|
36
|
+
transport TEXT NOT NULL,
|
|
37
|
+
received_at INTEGER NOT NULL,
|
|
38
|
+
payload TEXT NOT NULL
|
|
39
|
+
);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_otel_events_received_at ON otel_events(received_at);
|
|
41
|
+
|
|
42
|
+
CREATE TABLE IF NOT EXISTS spin_state (
|
|
43
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
44
|
+
spins_available INTEGER NOT NULL DEFAULT 0,
|
|
45
|
+
messages_since_last_spin INTEGER NOT NULL DEFAULT 0,
|
|
46
|
+
spin_threshold INTEGER NOT NULL DEFAULT 50
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
CREATE TABLE IF NOT EXISTS achievements (
|
|
50
|
+
id TEXT PRIMARY KEY,
|
|
51
|
+
earned_at INTEGER NOT NULL
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
CREATE TABLE IF NOT EXISTS daily_activity (
|
|
55
|
+
date TEXT PRIMARY KEY -- YYYY-MM-DD in local time
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
59
|
+
key TEXT PRIMARY KEY,
|
|
60
|
+
value TEXT NOT NULL
|
|
61
|
+
);
|
|
62
|
+
`;let k=null;function f(){if(k)return k;const e=E.join(u.app.getPath("userData"),"codeling.db");return k=new yt(e),k.pragma("journal_mode = WAL"),k.pragma("foreign_keys = ON"),k.exec(At),Nt(k),Mt(k),ge(k),k}function Nt(e){const t=e.pragma("table_info(sessions)").map(n=>n.name);t.includes("cost_usd")||e.exec("ALTER TABLE sessions ADD COLUMN cost_usd REAL NOT NULL DEFAULT 0"),t.includes("stop_event_count")||e.exec("ALTER TABLE sessions ADD COLUMN stop_event_count INTEGER NOT NULL DEFAULT 0")}function Mt(e){const t=Object.keys(R),n=new Set(t),s=e.prepare("SELECT species FROM pet WHERE id = 1").get();if(s&&!n.has(s.species)){const c=t[Math.floor(Math.random()*t.length)];e.prepare("UPDATE pet SET species = ? WHERE id = 1").run(c),console.log(`[migrate] active species '${s.species}' no longer valid; switched to '${c}'`)}const o=e.prepare("SELECT item_id FROM unlocks WHERE category = 'species'").all();for(const c of o){if(!c.item_id.startsWith("species:"))continue;const r=c.item_id.slice(8);n.has(r)||(e.prepare("DELETE FROM unlocks WHERE item_id = ?").run(c.item_id),console.log(`[migrate] dropped stale species unlock '${c.item_id}'`))}const i=e.prepare("SELECT item_id FROM unlocks WHERE category = 'animation'").all();for(const c of i){const r=c.item_id.startsWith("anim:")?c.item_id.slice(5):null;if(!r)continue;const l=r.indexOf(":");if(l<0)continue;const p=r.slice(0,l);n.has(p)||(e.prepare("DELETE FROM unlocks WHERE item_id = ?").run(c.item_id),console.log(`[migrate] dropped stale animation unlock '${c.item_id}'`))}const a=e.prepare("SELECT key FROM meta WHERE key LIKE 'home_animation:%'").all();for(const c of a){const r=c.key.slice(15);n.has(r)||e.prepare("DELETE FROM meta WHERE key = ?").run(c.key)}}function ge(e){const t=Date.now(),n=Object.keys(R),s=n[Math.floor(Math.random()*n.length)],o=R[s].label;e.prepare("INSERT OR IGNORE INTO pet (id, species, name, created_at) VALUES (1, ?, ?, ?)").run(s,o,t),e.prepare("INSERT OR IGNORE INTO spin_state (id) VALUES (1)").run(),e.prepare(`INSERT OR IGNORE INTO unlocks (item_id, category, acquired_via, acquired_at)
|
|
63
|
+
SELECT 'species:' || species, 'species', 'starter', created_at
|
|
64
|
+
FROM pet WHERE id = 1`).run()}function It(){k&&(k.close(),k=null)}class Ut extends bt.EventEmitter{emit(t,...n){return super.emit(t,...n)}on(t,n){return super.on(t,n)}off(t,n){return super.off(t,n)}}const L=new Ut;function Z(e=new Date){const t=e.getFullYear(),n=String(e.getMonth()+1).padStart(2,"0"),s=String(e.getDate()).padStart(2,"0");return`${t}-${n}-${s}`}function ye(e,t){const n=new Date(`${e}T00:00:00`);return n.setDate(n.getDate()+t),Z(n)}function Ye(){f().prepare("INSERT OR IGNORE INTO daily_activity (date) VALUES (?)").run(Z())}function Xe(){const e=f().prepare("SELECT date FROM daily_activity").all(),t=new Set(e.map(o=>o.date));let n=Z();if(!t.has(n)&&(n=ye(n,-1),!t.has(n)))return 0;let s=0;for(;t.has(n);)s+=1,n=ye(n,-1);return s}const be=Object.keys(R).length,ze=[{id:"first_message",label:"First word",description:"Send your first message",tier:"bronze",check:e=>e.totals.messages>=1},{id:"msg_100",label:"Chatterbox",description:"100 messages",tier:"silver",check:e=>e.totals.messages>=100},{id:"msg_1000",label:"Loquacious",description:"1,000 messages",tier:"gold",check:e=>e.totals.messages>=1e3},{id:"level_5",label:"Apprentice",description:"Reach level 5",tier:"bronze",check:e=>e.pet.level>=5},{id:"level_25",label:"Adept",description:"Reach level 25",tier:"silver",check:e=>e.pet.level>=25},{id:"level_100",label:"Master",description:"Reach level 100",tier:"gold",check:e=>e.pet.level>=100},{id:"unlock_species_2",label:"Collector",description:"Own 2 species",tier:"bronze",check:e=>e.unlockCounts.species>=2},{id:"unlock_species_5",label:"Menagerie",description:"Own 5 species",tier:"silver",check:e=>e.unlockCounts.species>=5},{id:"unlock_species_all",label:"Completionist",description:`Own all ${be} species`,tier:"gold",check:e=>e.unlockCounts.species>=be},{id:"first_animation",label:"Bringing it to life",description:"Unlock your first animation",tier:"bronze",check:e=>e.unlockCounts.animation>=1},{id:"animation_collector_10",label:"Animator",description:"Unlock 10 animations",tier:"silver",check:e=>e.unlockCounts.animation>=10},{id:"animation_master_25",label:"Master Animator",description:"Unlock 25 animations",tier:"gold",check:e=>e.unlockCounts.animation>=25},{id:"first_upgrade",label:"Power play",description:"Buy your first upgrade",tier:"silver",check:e=>e.unlockCounts.upgrade>=1},{id:"spend_1usd",label:"Tokens count",description:"$1 of session cost",tier:"bronze",check:e=>e.totals.costUsd>=1},{id:"spend_10usd",label:"Heavy lifting",description:"$10 of session cost",tier:"silver",check:e=>e.totals.costUsd>=10},{id:"streak_3",label:"Warming up",description:"3-day streak",tier:"bronze",check:e=>e.streakDays>=3},{id:"streak_7",label:"Habit",description:"7-day streak",tier:"silver",check:e=>e.streakDays>=7},{id:"streak_30",label:"Devout",description:"30-day streak",tier:"gold",check:e=>e.streakDays>=30}];function Ct(){const e=f(),t=e.prepare("SELECT level FROM pet WHERE id = 1").get(),n=e.prepare(`SELECT
|
|
65
|
+
COALESCE(SUM(message_count), 0) AS messages,
|
|
66
|
+
COALESCE(SUM(output_tokens), 0) AS output_tokens,
|
|
67
|
+
COALESCE(SUM(cost_usd), 0) AS cost_usd
|
|
68
|
+
FROM sessions`).get(),s=e.prepare(`SELECT
|
|
69
|
+
COALESCE(SUM(CASE WHEN category = 'species' THEN 1 ELSE 0 END), 0) AS species,
|
|
70
|
+
COALESCE(SUM(CASE WHEN category = 'upgrade' THEN 1 ELSE 0 END), 0) AS upgrade,
|
|
71
|
+
COALESCE(SUM(CASE WHEN category = 'animation' THEN 1 ELSE 0 END), 0) AS animation
|
|
72
|
+
FROM unlocks`).get();return{pet:{level:(t==null?void 0:t.level)??1},totals:{messages:(n==null?void 0:n.messages)??0,outputTokens:(n==null?void 0:n.output_tokens)??0,costUsd:(n==null?void 0:n.cost_usd)??0},unlockCounts:{species:(s==null?void 0:s.species)??0,upgrade:(s==null?void 0:s.upgrade)??0,animation:(s==null?void 0:s.animation)??0},streakDays:Xe()}}function X(e=!1){const t=f(),n=t.prepare("SELECT id FROM achievements").all(),s=new Set(n.map(r=>r.id)),o=Ct(),i=t.prepare("INSERT OR IGNORE INTO achievements (id, earned_at) VALUES (?, ?)"),a=Date.now(),c=[];for(const r of ze)s.has(r.id)||r.check(o)&&(i.run(r.id,a),c.push(r));if(!e)for(const r of c)L.emit("achievement:earned",{id:r.id,label:r.label,description:r.description,tier:r.tier,earnedAt:a})}var Te=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{},z={},$=1e3,P=$*60,F=P*60,C=F*24,Dt=C*7,xt=C*365.25,$t=function(e,t){t=t||{};var n=typeof e;if(n==="string"&&e.length>0)return Pt(e);if(n==="number"&&isFinite(e))return t.long?jt(e):Ft(e);throw new Error("val is not a non-empty string or a valid number. val="+JSON.stringify(e))};function Pt(e){if(e=String(e),!(e.length>100)){var t=/^(-?(?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i.exec(e);if(t){var n=parseFloat(t[1]),s=(t[2]||"ms").toLowerCase();switch(s){case"years":case"year":case"yrs":case"yr":case"y":return n*xt;case"weeks":case"week":case"w":return n*Dt;case"days":case"day":case"d":return n*C;case"hours":case"hour":case"hrs":case"hr":case"h":return n*F;case"minutes":case"minute":case"mins":case"min":case"m":return n*P;case"seconds":case"second":case"secs":case"sec":case"s":return n*$;case"milliseconds":case"millisecond":case"msecs":case"msec":case"ms":return n;default:return}}}}function Ft(e){var t=Math.abs(e);return t>=C?Math.round(e/C)+"d":t>=F?Math.round(e/F)+"h":t>=P?Math.round(e/P)+"m":t>=$?Math.round(e/$)+"s":e+"ms"}function jt(e){var t=Math.abs(e);return t>=C?V(e,t,C,"day"):t>=F?V(e,t,F,"hour"):t>=P?V(e,t,P,"minute"):t>=$?V(e,t,$,"second"):e+" ms"}function V(e,t,n,s){var o=t>=n*1.5;return Math.round(e/n)+" "+s+(o?"s":"")}var Ht=qt,Bt=/^(?:\w+:)?\/\/(\S+)$/,Wt=/^localhost[\:?\d]*(?:[^\:?\d]\S*)?$/,Gt=/^[^\s\.]+\.\S{2,}$/;function qt(e){if(typeof e!="string")return!1;var t=e.match(Bt);if(!t)return!1;var n=t[1];return n?!!(Wt.test(n)||Gt.test(n)):!1}var Yt=Ht,Xt=/(?:(?:[^:]+:)?[/][/])?(?:.+@)?([^/]+)([/][^?#]+)/,zt=function(e,t){var n={};if(t=t||{},!e||(e.url&&(e=e.url),typeof e!="string"))return null;var s=e.match(/^([\w-_]+)\/([\w-_\.]+)(?:#([\w-_\.]+))?$/),o=e.match(/^github:([\w-_]+)\/([\w-_\.]+)(?:#([\w-_\.]+))?$/),i=e.match(/^git@[\w-_\.]+:([\w-_]+)\/([\w-_\.]+)$/);if(s)n.user=s[1],n.repo=s[2],n.branch=s[3]||"master",n.host="github.com";else if(o)n.user=o[1],n.repo=o[2],n.branch=o[3]||"master",n.host="github.com";else if(i)n.user=i[1],n.repo=i[2].replace(/\.git$/i,""),n.branch="master",n.host="github.com";else{if(e=e.replace(/^git\+/,""),!Yt(e))return null;var a=e.match(Xt)||[],c=a[1],r=a[2];if(!c||c!=="github.com"&&c!=="www.github.com"&&!t.enterprise)return null;var l=r.match(/^\/([\w-_]+)\/([\w-_\.]+)(\/tree\/[\%\w-_\.\/]+)?(\/blob\/[\%\w-_\.\/]+)?/);if(!l)return null;if(n.user=l[1],n.repo=l[2].replace(/\.git$/i,""),n.host=c||"github.com",l[3]&&/^\/tree\/master\//.test(l[3]))n.branch="master",n.path=l[3].replace(/\/$/,"");else if(l[3]){var p=l[3].replace(/^\/tree\//,"").match(/[\%\w-_.]*\/?[\%\w-_]+/);n.branch=p&&p[0]}else if(l[4]){var p=l[4].replace(/^\/blob\//,"").match(/[\%\w-_.]*\/?[\%\w-_]+/);n.branch=p&&p[0]}else n.branch="master"}return n.host==="github.com"?n.apiHost="api.github.com":n.apiHost=n.host+"/api/v3",n.tarball_url="https://"+n.apiHost+"/repos/"+n.user+"/"+n.repo+"/tarball/"+n.branch,n.clone_url="https://"+n.host+"/"+n.user+"/"+n.repo,n.branch==="master"?(n.https_url="https://"+n.host+"/"+n.user+"/"+n.repo,n.travis_url="https://travis-ci.org/"+n.user+"/"+n.repo,n.zip_url="https://"+n.host+"/"+n.user+"/"+n.repo+"/archive/master.zip"):(n.https_url="https://"+n.host+"/"+n.user+"/"+n.repo+"/blob/"+n.branch,n.travis_url="https://travis-ci.org/"+n.user+"/"+n.repo+"?branch="+n.branch,n.zip_url="https://"+n.host+"/"+n.user+"/"+n.repo+"/archive/"+n.branch+".zip"),n.path&&(n.https_url+=n.path),n.api_url="https://"+n.apiHost+"/repos/"+n.user+"/"+n.repo,n};const Vt="update-electron-app",Kt="3.2.0",Jt={name:Vt,version:Kt};var j=Te&&Te.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(z,"__esModule",{value:!0});var Ve=z.UpdateSourceType=void 0,Qt=z.updateElectronApp=rn;z.makeUserNotifier=Ke;const ue=j($t),Zt=j(zt),O=j(Tt),en=j(T),Se=j(St),tn=j(E),nn=vt,w=u;var U;(function(e){e[e.ElectronPublicUpdateService=0]="ElectronPublicUpdateService",e[e.StaticStorage=1]="StaticStorage"})(U||(Ve=z.UpdateSourceType=U={}));const ve=Jt,sn=(0,nn.format)("%s/%s (%s: %s)",ve.name,ve.version,Se.default.platform(),Se.default.arch()),on=["darwin","win32"],we=e=>{try{const{protocol:t}=new URL(e);return t==="https:"}catch{return!1}};function rn(e={}){const t=cn(e);if(!w.app.isPackaged){const n="update-electron-app config looks good; aborting updates since app is in development mode";e.logger?e.logger.log(n):console.log(n);return}w.app.isReady()?Re(t):w.app.on("ready",()=>Re(t))}function Re(e){const{updateSource:t,updateInterval:n,logger:s}=e;if(!on.includes(process==null?void 0:process.platform)){c(`Electron's autoUpdater does not support the '${process.platform}' platform. Ref: https://www.electronjs.org/docs/latest/api/auto-updater#platform-notices`);return}let o,i="default";switch(t.type){case U.ElectronPublicUpdateService:{const r=process.windowsStore?"/msix":"";o=`${t.host}/${t.repo}/${process.platform}-${process.arch}${r}/${w.app.getVersion()}`;break}case U.StaticStorage:{o=t.baseUrl,process.platform==="darwin"&&(o+="/RELEASES.json",i="json");break}}const a={"User-Agent":sn};function c(...r){s.log(...r)}c("feedURL",o),c("requestHeaders",a),w.autoUpdater.setFeedURL({url:o,headers:a,serverType:i}),w.autoUpdater.on("error",r=>{c("updater error"),c(r)}),w.autoUpdater.on("checking-for-update",()=>{c("checking-for-update")}),w.autoUpdater.on("update-available",()=>{c("update-available; downloading...")}),w.autoUpdater.on("update-not-available",()=>{c("update-not-available")}),e.notifyUser&&w.autoUpdater.on("update-downloaded",(r,l,p,d,g)=>{c("update-downloaded",[r,l,p,d,g]),typeof e.onNotifyUser!="function"?((0,O.default)(e.onNotifyUser===void 0,"onNotifyUser option must be a callback function or undefined"),c("update-downloaded: notifyUser is true, opening default dialog"),e.onNotifyUser=Ke()):c("update-downloaded: notifyUser is true, running custom onNotifyUser callback"),e.onNotifyUser({event:r,releaseNotes:l,releaseDate:d,releaseName:p,updateURL:g})}),w.autoUpdater.checkForUpdates(),setInterval(()=>{w.autoUpdater.checkForUpdates()},(0,ue.default)(n))}function Ke(e){const n=Object.assign({},{title:"Application Update",detail:"A new version has been downloaded. Restart the application to apply the updates.",restartButtonText:"Restart",laterButtonText:"Later"},e);return s=>{const{releaseNotes:o,releaseName:i}=s,{title:a,restartButtonText:c,laterButtonText:r,detail:l}=n,p={type:"info",buttons:[c,r],title:a,message:process.platform==="win32"?o:i,detail:l};w.dialog.showMessageBox(p).then(({response:d})=>{d===0&&w.autoUpdater.quitAndInstall()})}}function an(){var e;const t=en.default.readFileSync(tn.default.join(w.app.getAppPath(),"package.json")),n=JSON.parse(t.toString()),s=((e=n.repository)===null||e===void 0?void 0:e.url)||n.repository,o=(0,Zt.default)(s);return(0,O.default)(o,"repo not found. Add repository string to your app's package.json file"),`${o.user}/${o.repo}`}function cn(e){var t;const n={host:"https://update.electronjs.org",updateInterval:"10 minutes",logger:console,notifyUser:!0},{host:s,updateInterval:o,logger:i,notifyUser:a,onNotifyUser:c}=Object.assign({},n,e);let r=e.updateSource;switch(r||(r={type:U.ElectronPublicUpdateService,repo:e.repo||an(),host:s}),r.type){case U.ElectronPublicUpdateService:{(0,O.default)((t=r.repo)===null||t===void 0?void 0:t.includes("/"),"repo is required and should be in the format `owner/repo`"),r.host||(r.host=s),(0,O.default)(r.host&&we(r.host),"host must be a valid HTTPS URL");break}case U.StaticStorage:{(0,O.default)(r.baseUrl&&we(r.baseUrl),"baseUrl must be a valid HTTPS URL");break}}return(0,O.default)(typeof o=="string"&&o.match(/^\d+/),"updateInterval must be a human-friendly string interval like `20 minutes`"),(0,O.default)((0,ue.default)(o)>=5*60*1e3,"updateInterval must be `5 minutes` or more"),(0,O.default)((0,ue.default)(o)<2**31,"updateInterval must fit in a signed 32-bit integer"),(0,O.default)(i&&typeof i.log,"function"),{updateSource:r,updateInterval:o,logger:i,notifyUser:a,onNotifyUser:c}}const ln="auto_update_enabled";function un(){try{const e=f().prepare("SELECT value FROM meta WHERE key = ?").get(ln);return e?e.value==="1":!0}catch{return!0}}function pn(){if(!u.app.isPackaged){console.log("[updater] skipped — dev mode");return}if(!un()){console.log("[updater] skipped — disabled in meta");return}try{Qt({updateSource:{type:Ve.ElectronPublicUpdateService,repo:"tdodd777/Codeling"},updateInterval:"10 minutes",logger:{log:(...e)=>console.log("[updater]",...e),info:(...e)=>console.log("[updater]",...e),warn:(...e)=>console.warn("[updater]",...e),error:(...e)=>console.error("[updater]",...e)},notifyUser:!0})}catch(e){console.error("[updater] init failed",e)}}const se="last_summary_date";function ke(e){const t=new Date(e);return t.setHours(0,0,0,0),t.getTime()}function dn(e){const t=f().prepare("SELECT value FROM meta WHERE key = ?").get(e);return t==null?void 0:t.value}function Le(e,t){f().prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(e,t)}function fn(){const e=new Date,t=ke(e),n=new Date(e);n.setDate(n.getDate()-1);const s=ke(n),o=f().prepare(`SELECT
|
|
73
|
+
COUNT(*) AS sessions,
|
|
74
|
+
COALESCE(SUM(message_count), 0) AS messages,
|
|
75
|
+
COALESCE(SUM(cost_usd), 0) AS cost
|
|
76
|
+
FROM sessions
|
|
77
|
+
WHERE last_seen_at >= ? AND last_seen_at < ?`).get(s,t);return{sessions:(o==null?void 0:o.sessions)??0,messages:(o==null?void 0:o.messages)??0,costUsd:(o==null?void 0:o.cost)??0}}function Oe(e){const t=[];if(t.push(`${e.sessions} session${e.sessions===1?"":"s"}`),t.push(`${e.messages} message${e.messages===1?"":"s"}`),e.costUsd>0){const n=e.costUsd>=1?e.costUsd.toFixed(2):e.costUsd.toFixed(4).replace(/0+$/,"").replace(/\.$/,"");t.push(`$${n}`)}return t.join(" · ")}function Je(){const e=Z();if(dn(se)===e)return!1;const t=fn();return t.messages===0&&t.sessions===0?(Le(se,e),!1):(u.Notification.isSupported()&&new u.Notification({title:"Yesterday in Codeling",body:Oe(t),silent:!0}).show(),console.log(`[daily-summary] ${Oe(t)}`),Le(se,e),!0)}const mn=new Set(["message_count","input_tokens","output_tokens","cache_read_tokens","cache_creation_tokens","cost_usd"]);function M(){const e=f().prepare("SELECT * FROM pet WHERE id = 1").get();if(!e)throw new Error("pet row missing — seed did not run");return{species:e.species,name:e.name,level:e.level,xp:e.xp,bits:e.bits,createdAt:e.created_at}}const Qe="home_animation:";function gn(e){const t=f().prepare("SELECT value FROM meta WHERE key = ?").get(`${Qe}${e}`);return(t==null?void 0:t.value)??"idle"}function En(e,t){f().prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(`${Qe}${e}`,t)}function hn(e){if(!(e in R))return{error:"not-owned"};const t=f();if(!t.prepare("SELECT item_id FROM unlocks WHERE category = 'species' AND item_id = ?").get(`species:${e}`))return{error:"not-owned"};t.prepare("UPDATE pet SET species = ? WHERE id = 1").run(e);const s=M();return{ok:!0,species:e,name:s.name}}function _n(e){if(!Number.isFinite(e)||!Number.isInteger(e))throw new Error("not-integer");if(e<Ge||e>qe)throw new Error("out-of-range");return f().prepare("UPDATE spin_state SET spin_threshold = ? WHERE id = 1").run(e),e}function yn(){const e=f();e.transaction(()=>{e.exec("DELETE FROM otel_events"),e.exec("DELETE FROM achievements"),e.exec("DELETE FROM daily_activity"),e.exec("DELETE FROM meta"),e.exec("DELETE FROM unlocks"),e.exec("DELETE FROM sessions"),e.exec("DELETE FROM spin_state"),e.exec("DELETE FROM pet"),ge(e)})()}function bn(e){const t=e.trim();if(t.length===0)throw new Error("empty-name");if(t.length>Ot)throw new Error("name-too-long");return f().prepare("UPDATE pet SET name = ? WHERE id = 1").run(t),t}function Tn(){const e=f().prepare("SELECT * FROM spin_state WHERE id = 1").get();if(!e)throw new Error("spin_state row missing — seed did not run");return{spinsAvailable:e.spins_available,messagesSinceLastSpin:e.messages_since_last_spin,spinThreshold:e.spin_threshold}}function Sn(){const e=f().prepare(`SELECT
|
|
78
|
+
COALESCE(SUM(message_count), 0) AS total_messages,
|
|
79
|
+
COALESCE(SUM(input_tokens), 0) AS total_input,
|
|
80
|
+
COALESCE(SUM(output_tokens), 0) AS total_output,
|
|
81
|
+
COALESCE(SUM(cache_read_tokens), 0) AS total_cache_read,
|
|
82
|
+
COALESCE(SUM(cache_creation_tokens), 0) AS total_cache_create,
|
|
83
|
+
COALESCE(SUM(cost_usd), 0) AS total_cost,
|
|
84
|
+
COUNT(*) AS session_count
|
|
85
|
+
FROM sessions`).get();return{totalMessages:(e==null?void 0:e.total_messages)??0,totalInputTokens:(e==null?void 0:e.total_input)??0,totalOutputTokens:(e==null?void 0:e.total_output)??0,totalCacheReadTokens:(e==null?void 0:e.total_cache_read)??0,totalCacheCreationTokens:(e==null?void 0:e.total_cache_create)??0,totalCostUsd:(e==null?void 0:e.total_cost)??0,sessionCount:(e==null?void 0:e.session_count)??0}}function vn(){return f().prepare("SELECT item_id, category, acquired_via, acquired_at FROM unlocks ORDER BY acquired_at DESC").all().map(t=>{let n=t.item_id,s="common";if(t.category==="species"&&t.item_id.startsWith("species:")){const o=t.item_id.slice(8),i=R[o];i&&(n=i.label,s=i.tier)}return{itemId:t.item_id,category:t.category,acquiredVia:t.acquired_via,acquiredAt:t.acquired_at,label:n,tier:s}})}function wn(){const e=f().prepare("SELECT id, earned_at FROM achievements").all(),t=new Map(e.map(n=>[n.id,n.earned_at]));return ze.map(n=>{const s=t.get(n.id);return{id:n.id,label:n.label,description:n.description,tier:n.tier,earned:s!==void 0,earnedAt:s}})}function Rn(e,t,n){f().prepare("INSERT INTO otel_events (signal_type, transport, received_at, payload) VALUES (?, ?, ?, ?)").run(e,t,Date.now(),JSON.stringify(n))}function kn(e){if(e.length===0)return;const t=f(),n=t.prepare("INSERT OR IGNORE INTO sessions (session_id, started_at, last_seen_at) VALUES (?, ?, ?)"),s=t.prepare("UPDATE sessions SET last_seen_at = MAX(last_seen_at, ?) WHERE session_id = ?"),o=new Map;function i(c){let r=o.get(c);return r||(r=t.prepare(`UPDATE sessions
|
|
86
|
+
SET ${c} = ${c} + ?,
|
|
87
|
+
last_seen_at = MAX(last_seen_at, ?)
|
|
88
|
+
WHERE session_id = ?`),o.set(c,r)),r}t.transaction(c=>{for(const r of c)if(n.run(r.sessionId,r.timestampMs,r.timestampMs),r.field&&r.delta&&r.delta!==0){if(!mn.has(r.field))continue;i(r.field).run(r.delta,r.timestampMs,r.sessionId)}else s.run(r.timestampMs,r.sessionId)})(e)}const Ze=["xpPerMessage","xpPerOutputTokens","bitsPerMessage","bitsPerOutputTokens"],Ln={xpPerMessage:10,xpPerOutputTokens:100,bitsPerMessage:5,bitsPerOutputTokens:1e3},pe={xpPerMessage:{min:0,max:1e3},xpPerOutputTokens:{min:1,max:1e6},bitsPerMessage:{min:0,max:1e3},bitsPerOutputTokens:{min:1,max:1e6}},Q={xpForLevel:e=>e*100};function et(e){return`economy:${e}`}function ee(){const t=f().prepare("SELECT key, value FROM meta WHERE key LIKE 'economy:%'").all(),n=new Map(t.map(o=>[o.key,o.value])),s={};for(const o of Ze){const i=n.get(et(o)),a=i!==void 0?Number(i):NaN;s[o]=Number.isFinite(a)?a:Ln[o]}return s}function On(e,t){if(!Ze.includes(e))throw new Error("unknown-key");if(!Number.isInteger(t))throw new Error("not-integer");const{min:n,max:s}=pe[e];if(t<n||t>s)throw new Error("out-of-range");return f().prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(et(e),String(t)),ee()}function An(){return f().prepare("DELETE FROM meta WHERE key LIKE 'economy:%'").run(),ee()}function tt(e){const t={xpGained:0,bitsGained:0,levelsGained:0,spinsGranted:0,changed:!1};if(e.messages<=0&&e.outputTokens<=0)return t;const n=ee(),s=e.messages*n.xpPerMessage+Math.floor(e.outputTokens/n.xpPerOutputTokens);let o=e.messages*n.bitsPerMessage+Math.floor(e.outputTokens/n.bitsPerOutputTokens);if(s===0&&o===0&&e.messages===0)return t;const i=f();return i.transaction(()=>{!!i.prepare("SELECT item_id FROM unlocks WHERE category = 'upgrade' AND item_id = 'bit_multiplier_2x' LIMIT 1").get()&&(o*=2);const r=i.prepare("SELECT level, xp, bits FROM pet WHERE id = 1").get();if(!r)throw new Error("pet row missing");let l=r.level,p=r.xp+s,d=0;for(;p>=Q.xpForLevel(l)&&(p-=Q.xpForLevel(l),l+=1,d+=1,!(d>100)););const g=r.bits+o;if(i.prepare("UPDATE pet SET level = ?, xp = ?, bits = ? WHERE id = 1").run(l,p,g),t.xpGained=s,t.bitsGained=o,t.levelsGained=d,e.messages>0){const b=i.prepare("SELECT spins_available, messages_since_last_spin, spin_threshold FROM spin_state WHERE id = 1").get();if(!b)throw new Error("spin_state row missing");let m=b.messages_since_last_spin+e.messages,y=0;for(;m>=b.spin_threshold&&(m-=b.spin_threshold,y+=1,!(y>100)););i.prepare("UPDATE spin_state SET spins_available = spins_available + ?, messages_since_last_spin = ? WHERE id = 1").run(y,m),t.spinsGranted=y}t.changed=!0})(),t}let oe=!1;const Nn="codeling:update";function S(){oe||(oe=!0,setImmediate(()=>{oe=!1;for(const e of u.BrowserWindow.getAllWindows())e.isDestroyed()||e.webContents.send(Nn)}))}let re=null;function nt(){return u.app.isPackaged?E.join(process.resourcesPath,"proto"):E.join(u.app.getAppPath(),"proto")}async function Mn(){if(re)return re;const e=nt(),t=new kt.Root;return t.resolvePath=(n,s)=>E.isAbsolute(s)?s:s.startsWith("opentelemetry/")?E.join(e,s):s,await t.load(["opentelemetry/proto/collector/trace/v1/trace_service.proto","opentelemetry/proto/collector/metrics/v1/metrics_service.proto","opentelemetry/proto/collector/logs/v1/logs_service.proto"].map(n=>E.join(e,n)),{keepCase:!0}),re=t,t}async function In(){const e=await Mn(),t=e.lookupType("opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest"),n=e.lookupType("opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest"),s=e.lookupType("opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest"),o={longs:Number,enums:String,bytes:String,defaults:!1};return{decodeTraces:i=>t.toObject(t.decode(i),o),decodeMetrics:i=>n.toObject(n.decode(i),o),decodeLogs:i=>s.toObject(s.decode(i),o)}}function Un(){return nt()}function _(e,...t){if(!e||typeof e!="object")return;const n=e;for(const s of t){const o=n[s];if(o!=null)return o}}function Cn(e){if(!e)return;const t=_(e,"string_value","stringValue");if(t!==void 0)return t;const n=_(e,"int_value","intValue");if(n!==void 0)return Number(n);const s=_(e,"double_value","doubleValue");if(s!==void 0)return s;const o=_(e,"bool_value","boolValue");if(o!==void 0)return o}function q(e,t){if(Array.isArray(e)){for(const n of e)if((n==null?void 0:n.key)===t)return Cn(n.value)}}function Dn(e){const t=_(e,"as_int","asInt");if(t!==void 0)return Number(t);const n=_(e,"as_double","asDouble");return n!==void 0?n:0}function Ee(e){if(e==null)return Date.now();const t=Number(e);return!Number.isFinite(t)||t<=0?Date.now():Math.round(t/1e6)}const xn={input:"input_tokens",output:"output_tokens",cacheRead:"cache_read_tokens",cacheCreation:"cache_creation_tokens"};function $n(e){const t=[],n=_(e,"resource_metrics","resourceMetrics")??[];for(const s of n){const o=_(s,"scope_metrics","scopeMetrics")??[];for(const i of o){const a=_(i,"metrics")??[];for(const c of a){const r=_(c,"name");if(r!=="claude_code.token.usage"&&r!=="claude_code.cost.usage")continue;const l=_(c,"sum");if(!l)continue;if(Number(_(l,"aggregation_temporality","aggregationTemporality")??0)===2){console.warn(`[otel] skipping ${r}: CUMULATIVE temporality not supported (DELTA expected)`);continue}const d=_(l,"data_points","dataPoints")??[];for(const g of d){const b=g,m=_(b,"attributes"),y=String(q(m,"session.id")??"");if(!y)continue;let v;if(r==="claude_code.token.usage"){const ht=String(q(m,"type")??"");v=xn[ht]}else v="cost_usd";if(!v)continue;const A=Dn(b);if(A<=0)continue;const Et=Ee(_(b,"time_unix_nano","timeUnixNano"));t.push({sessionId:y,timestampMs:Et,field:v,delta:A})}}}}return t}function Pn(e){const t=[],n=_(e,"resource_logs","resourceLogs")??[];for(const s of n){const o=_(s,"scope_logs","scopeLogs")??[];for(const i of o){const a=_(i,"log_records","logRecords")??[];for(const c of a){const r=c,l=_(r,"attributes"),p=String(q(l,"session.id")??"");if(!p)continue;const d=Ee(_(r,"time_unix_nano","timeUnixNano"));String(q(l,"event.name")??"")==="user_prompt"?t.push({sessionId:p,timestampMs:d,field:"message_count",delta:1}):t.push({sessionId:p,timestampMs:d})}}}return t}function Fn(e){const t=[],n=_(e,"resource_spans","resourceSpans")??[];for(const s of n){const o=_(s,"scope_spans","scopeSpans")??[];for(const i of o){const a=_(i,"spans")??[];for(const c of a){const r=c,l=_(r,"attributes"),p=String(q(l,"session.id")??"");if(!p)continue;const d=Ee(_(r,"end_time_unix_nano","endTimeUnixNano"));t.push({sessionId:p,timestampMs:d})}}}return t}function x(e,t,n){Rn(e,t,n);let s=[];switch(e){case"metric":s=$n(n);break;case"log":s=Pn(n);break;case"trace":s=Fn(n);break}kn(s);const o=jn(s);o.message_count>0&&(Ye(),Je());const i=tt({messages:o.message_count,outputTokens:o.output_tokens});(i.changed||s.length>0)&&X();const a=Bn(e,n),c=Hn(o),r=i.changed?` | +xp=${i.xpGained} +bits=${i.bitsGained}`+(i.levelsGained?` +levels=${i.levelsGained}`:"")+(i.spinsGranted?` +spins=${i.spinsGranted}`:""):"";console.log(`[otel:${t}] ${e} ${a}${c}${r}`),s.length>0&&S()}function jn(e){const t=new Set,n={sessions:0,message_count:0,input_tokens:0,output_tokens:0,cache_read_tokens:0,cache_creation_tokens:0,cost_usd:0};for(const s of e)t.add(s.sessionId),s.field&&s.delta&&(n[s.field]+=s.delta);return n.sessions=t.size,n}function Hn(e){if(e.sessions===0)return"";const t=[`sessions=${e.sessions}`];for(const[n,s]of Object.entries(e)){if(n==="sessions"||s===0)continue;const o=n==="cost_usd"?`$${s.toFixed(4)}`:s;t.push(`+${n}=${o}`)}return` → ${t.join(" ")}`}function Bn(e,t){if(!t||typeof t!="object")return"";const n=t;switch(e){case"trace":{const s=n.resourceSpans??n.resource_spans,o=ie(s,"scopeSpans","scope_spans","spans");return`resourceSpans=${(s==null?void 0:s.length)??0} spans=${o}`}case"metric":{const s=n.resourceMetrics??n.resource_metrics,o=ie(s,"scopeMetrics","scope_metrics","metrics");return`resourceMetrics=${(s==null?void 0:s.length)??0} metrics=${o}`}case"log":{const s=n.resourceLogs??n.resource_logs,o=ie(s,"scopeLogs","scope_logs","logRecords","log_records");return`resourceLogs=${(s==null?void 0:s.length)??0} records=${o}`}}}function ie(e,t,n,...s){if(!e)return 0;let o=0;for(const i of e){if(!i||typeof i!="object")continue;const a=i,c=a[t]??a[n];if(c)for(const r of c){if(!r||typeof r!="object")continue;const l=r;for(const p of s){const d=l[p];Array.isArray(d)&&(o+=d.length)}}}return o}const Wn=4317,Ae="127.0.0.1",Gn=["opentelemetry/proto/collector/trace/v1/trace_service.proto","opentelemetry/proto/collector/metrics/v1/metrics_service.proto","opentelemetry/proto/collector/logs/v1/logs_service.proto"];async function qn(){const e=Un(),t=Lt.loadSync(Gn.map(o=>E.join(e,o)),{keepCase:!0,longs:Number,enums:String,bytes:String,defaults:!1,oneofs:!0,includeDirs:[e]}),n=ne.loadPackageDefinition(t),s=new ne.Server;return s.addService(n.opentelemetry.proto.collector.trace.v1.TraceService.service,{Export:(o,i)=>{try{x("trace","grpc",o.request),i(null,{partialSuccess:{}})}catch(a){i(a)}}}),s.addService(n.opentelemetry.proto.collector.metrics.v1.MetricsService.service,{Export:(o,i)=>{try{x("metric","grpc",o.request),i(null,{partialSuccess:{}})}catch(a){i(a)}}}),s.addService(n.opentelemetry.proto.collector.logs.v1.LogsService.service,{Export:(o,i)=>{try{x("log","grpc",o.request),i(null,{partialSuccess:{}})}catch(a){i(a)}}}),new Promise((o,i)=>{s.bindAsync(`${Ae}:${Wn}`,ne.ServerCredentials.createInsecure(),(a,c)=>{if(a)return i(a);console.log(`[otel:grpc] listening on ${Ae}:${c}`),o(s)})})}function Yn(e){const t=typeof e.session_id=="string"?e.session_id:"";if(!t)return{error:"no-session-id"};const n=f(),s=Date.now();let o=0;return n.transaction(()=>{n.prepare("INSERT OR IGNORE INTO sessions (session_id, started_at, last_seen_at) VALUES (?, ?, ?)").run(t,s,s),n.prepare(`UPDATE sessions
|
|
89
|
+
SET stop_event_count = stop_event_count + 1,
|
|
90
|
+
last_seen_at = MAX(last_seen_at, ?)
|
|
91
|
+
WHERE session_id = ?`).run(s,t);const a=n.prepare("SELECT message_count, stop_event_count FROM sessions WHERE session_id = ?").get(t);a&&a.stop_event_count>a.message_count&&(o=a.stop_event_count-a.message_count,n.prepare("UPDATE sessions SET message_count = ? WHERE session_id = ?").run(a.stop_event_count,t))})(),o>0&&(Ye(),tt({messages:o,outputTokens:0}),X(),S()),{ok:!0,messagesAdded:o}}const Ne=4318,Me="127.0.0.1";async function Xn(){const e=await In(),t=te();return t.use(te.raw({type:"application/x-protobuf",limit:"10mb"})),t.use(te.json({type:"application/json",limit:"10mb"})),t.post("/v1/traces",(n,s)=>{try{const o=Buffer.isBuffer(n.body)?e.decodeTraces(n.body):n.body;x("trace","http",o),s.set("Content-Type","application/x-protobuf").status(200).send(Buffer.alloc(0))}catch(o){console.error("[otel:http] trace decode failed",o),s.status(400).end()}}),t.post("/v1/metrics",(n,s)=>{try{const o=Buffer.isBuffer(n.body)?e.decodeMetrics(n.body):n.body;x("metric","http",o),s.set("Content-Type","application/x-protobuf").status(200).send(Buffer.alloc(0))}catch(o){console.error("[otel:http] metric decode failed",o),s.status(400).end()}}),t.post("/v1/logs",(n,s)=>{try{const o=Buffer.isBuffer(n.body)?e.decodeLogs(n.body):n.body;x("log","http",o),s.set("Content-Type","application/x-protobuf").status(200).send(Buffer.alloc(0))}catch(o){console.error("[otel:http] log decode failed",o),s.status(400).end()}}),t.post("/codeling/stop-hook",(n,s)=>{try{const o=typeof n.body=="object"&&n.body!==null?n.body:{},i=Yn(o);if("error"in i){s.status(400).json(i);return}const a=typeof o.session_id=="string"?o.session_id:"?";console.log(`[stop-hook] sid=${a.slice(0,8)} +msg=${i.messagesAdded}`),s.status(204).end()}catch(o){console.error("[stop-hook] failed",o),s.status(500).end()}}),new Promise((n,s)=>{const o=t.listen(Ne,Me,()=>{console.log(`[otel:http] listening on http://${Me}:${Ne}`),n(o)});o.on("error",s)})}const st="telemetry_enabled";let W=null,G=null,ae=!1,ce=!1;function ot(){const e=f().prepare("SELECT value FROM meta WHERE key = ?").get(st);return e?e.value==="1":!0}function zn(e){f().prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(st,e?"1":"0")}function he(){return!!W||!!G}async function rt(){if(!(he()||ae)){ae=!0;try{W=await Xn()}catch(e){console.error("[otel:http] failed to start",e)}try{G=await qn()}catch(e){console.error("[otel:grpc] failed to start",e)}ae=!1}}async function Vn(){if(!ce){if(ce=!0,W){const e=W;W=null,await new Promise(t=>{let n=!1;const s=()=>{n||(n=!0,t())};e.close(()=>s());const o=setTimeout(s,1e3);typeof o.unref=="function"&&o.unref()}),console.log("[otel:http] stopped")}if(G){const e=G;G=null,await new Promise(t=>{let n=!1;const s=()=>{n||(n=!0,t())};e.tryShutdown(()=>s());const o=setTimeout(()=>{try{e.forceShutdown()}catch{}s()},1e3);typeof o.unref=="function"&&o.unref()}),console.log("[otel:grpc] stopped")}ce=!1}}async function Kn(e){return zn(e),e?await rt():await Vn(),he()}const it="popout:bounds",D={x:100,y:100,width:480,height:720};let B=null,h=null;function Jn(e){B=e;const t=e.mb.tray;t&&(t.removeAllListeners("click"),t.on("click",()=>{var n;if(de()&&h&&!h.isDestroyed()){h.isMinimized()&&h.restore(),h.show(),h.focus();return}(n=e.mb.window)!=null&&n.isVisible()?e.mb.hideWindow():e.mb.showWindow()})),e.mb.on("after-show",()=>{de()&&(e.mb.hideWindow(),h&&!h.isDestroyed()&&(h.show(),h.focus()))})}function de(){return!!h&&!h.isDestroyed()}function Qn(){if(!B)return{error:"not-initialized"};if(B.mb.hideWindow(),h&&!h.isDestroyed())return h.isMinimized()&&h.restore(),h.show(),h.focus(),S(),{ok:!0};const e=es();return h=new u.BrowserWindow({x:e.x,y:e.y,width:e.width,height:e.height,minWidth:380,minHeight:480,title:"Codeling",autoHideMenuBar:!0,webPreferences:{preload:B.preloadPath,contextIsolation:!0,nodeIntegration:!1,sandbox:!1}}),h.loadURL(B.rendererUrl),h.on("close",()=>{h&&!h.isDestroyed()&&ts(h.getBounds())}),h.on("closed",()=>{h=null,S()}),S(),{ok:!0}}function Zn(){h&&!h.isDestroyed()&&h.close()}function es(){const e=f().prepare("SELECT value FROM meta WHERE key = ?").get(it);if(!e)return D;try{const t=JSON.parse(e.value);return{x:Number.isFinite(t.x)?t.x:D.x,y:Number.isFinite(t.y)?t.y:D.y,width:Number.isFinite(t.width)&&t.width>=380?t.width:D.width,height:Number.isFinite(t.height)&&t.height>=480?t.height:D.height}}catch{return D}}function ts(e){f().prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(it,JSON.stringify(e))}const fe=1,Ie=["pet","sessions","unlocks","spin_state","achievements","daily_activity","meta"];function I(e){return f().prepare(`SELECT * FROM ${e}`).all()}function ns(){return{version:fe,exportedAt:Date.now(),pet:I("pet"),sessions:I("sessions"),unlocks:I("unlocks"),spin_state:I("spin_state"),achievements:I("achievements"),daily_activity:I("daily_activity"),meta:I("meta")}}async function ss(){const e=`codeling-save-${new Date().toISOString().slice(0,10)}.json`,t=await u.dialog.showSaveDialog({title:"Export Codeling save",defaultPath:e,filters:[{name:"Codeling save",extensions:["json"]}]});if(t.canceled||!t.filePath)return{error:"cancelled"};try{const n=JSON.stringify(ns(),null,2);return await Be.writeFile(t.filePath,n,"utf8"),{ok:!0,path:t.filePath}}catch(n){return{error:"write-failed",detail:n.message}}}function os(e){const t=f();t.transaction(()=>{t.exec("DELETE FROM otel_events");for(const s of Ie)t.exec(`DELETE FROM ${s}`);for(const s of Ie){const o=e[s];if(!Array.isArray(o)||o.length===0)continue;const i=t.pragma(`table_info(${s})`).map(c=>c.name),a=new Set(i);for(const c of o){const r=Object.keys(c).filter(d=>a.has(d));if(r.length===0)continue;const l=r.map(()=>"?").join(", ");t.prepare(`INSERT OR REPLACE INTO ${s} (${r.join(", ")}) VALUES (${l})`).run(...r.map(d=>c[d]))}}ge(t)})()}async function rs(){const e=await u.dialog.showOpenDialog({title:"Import Codeling save",filters:[{name:"Codeling save",extensions:["json"]}],properties:["openFile"]});if(e.canceled||e.filePaths.length===0)return{error:"cancelled"};const t=e.filePaths[0];let n;try{n=await Be.readFile(t,"utf8")}catch(i){return{error:"read-failed",detail:i.message}}let s;try{s=JSON.parse(n)}catch(i){return{error:"invalid-format",detail:i.message}}if(!s||typeof s!="object")return{error:"invalid-format",detail:"not an object"};const o=s;return typeof o.version!="number"?{error:"invalid-format",detail:"missing version"}:o.version>fe?{error:"unsupported-version",detail:`save v${o.version} > app v${fe}`}:(os(o),{ok:!0,path:t})}const le={n:"north",north:"north",ne:"northeast",northeast:"northeast","north-east":"northeast",e:"east",east:"east",se:"southeast",southeast:"southeast","south-east":"southeast",s:"south",south:"south",sw:"southwest",southwest:"southwest","south-west":"southwest",w:"west",west:"west",nw:"northwest",northwest:"northwest","north-west":"northwest"};function is(e){const t=e.replace(/-[a-f0-9]{6,}$/i,"").toLowerCase(),n=new Set([t]);return/idle|breath/.test(t)&&n.add("idle"),/run/.test(t)&&n.add("run"),/walk/.test(t)&&n.add("walk"),/attack|cast|fight|hit/.test(t)&&n.add("attack"),[...n]}function me(e){const t=e.replace(/-[a-f0-9]{6,}$/i,"").toLowerCase();return/idle|breath/.test(t)?"idle":/^run/.test(t)?"run":/^walk/.test(t)?"walk":/^(attack|cast|fight|hit)$/.test(t)?"attack":t}function at(){return u.app.isPackaged?E.join(process.resourcesPath,"assets","sprites"):E.join(u.app.getAppPath(),"assets","sprites")}function ct(e){return{dir:E.join(at(),e),urlSegments:["./sprites",e]}}function H(e,...t){return[...e,...t].join("/")}function N(e){try{return T.statSync(e).isDirectory()?T.readdirSync(e):[]}catch{return[]}}function K(e){const t=e.match(/_(\d+)\.png$/i);return t&&t[1]?parseInt(t[1],10):-1}function Ue(e){let t=0;for(const n of Object.values(e))t+=(n==null?void 0:n.length)??0;return t}function Ce(e,t){return e?Ue(t)>Ue(e):!0}function as(e){const t=E.join(at(),e,"background.png");try{if(T.statSync(t).isFile())return["./sprites",e,"background.png"].join("/")}catch{}}function lt(e){const{dir:t}=ct(e),n=new Set;for(const s of N(E.join(t,"animations"))){const o=E.join(t,"animations",s);try{if(!T.statSync(o).isDirectory())continue}catch{continue}n.add(me(s))}if(!T.existsSync(E.join(t,"animations"))){for(const s of N(t))if(!(s==="rotations"||s==="metadata.json"))try{T.statSync(E.join(t,s)).isDirectory()&&n.add(me(s))}catch{}}return[...n]}function cs(e,t){var r;const{dir:n,urlSegments:s}=ct(e),o={static:H(s,"rotations","south.png"),animations:{}},i=as(e);if(i&&(o.background=i),!T.existsSync(n))return console.warn(`[sprites] no sprite directory for ${e} at ${n}`),o;const a=l=>{if(!t)return!0;const p=me(l);return p==="idle"?!0:t.has(p)};for(const l of N(E.join(n,"rotations"))){if(!l.toLowerCase().endsWith(".png"))continue;const p=l.replace(/\.png$/i,"").toLowerCase(),d=le[p];if(!d)continue;d==="south"&&(o.static=H(s,"rotations",l));const g=(r=o.animations).static??(r.static={});g[d]=[H(s,"rotations",l)]}for(const l of N(E.join(n,"animations"))){const p=E.join(n,"animations",l);if(!T.statSync(p).isDirectory()||!a(l))continue;const d={};for(const g of N(p)){const b=E.join(p,g);if(!T.statSync(b).isDirectory())continue;const m=le[g.toLowerCase()];if(!m)continue;const y=N(b).filter(v=>v.toLowerCase().endsWith(".png")).sort((v,A)=>K(v)-K(A)).map(v=>H(s,"animations",l,g,v));y.length>0&&(d[m]=y)}if(Object.keys(d).length>0)for(const g of is(l))Ce(o.animations[g],d)&&(o.animations[g]=d)}for(const l of N(n)){if(l==="rotations"||l==="animations"||l==="metadata.json")continue;const p=E.join(n,l);if(!T.statSync(p).isDirectory()||!a(l))continue;const d={};for(const g of N(p)){if(!g.toLowerCase().endsWith(".png"))continue;const b=g.replace(/\.png$/i,"").match(/^([a-z-]+?)(?:_(\d+))?$/i);if(!b||!b[1])continue;const m=le[b[1].toLowerCase()];m&&(d[m]??(d[m]=[])).push(H(s,l,g))}for(const g of Object.keys(d))d[g].sort((b,m)=>K(b)-K(m));if(Object.keys(d).length>0){const g=l.toLowerCase();Ce(o.animations[g],d)&&(o.animations[g]=d)}}const c=Object.entries(o.animations).map(([l,p])=>`${l}(${Object.entries(p).map(([d,g])=>`${d}=${g.length}`).join(",")})`).join(" ");return console.log(`[sprites] ${e}: ${c||"(only static fallback)"}`),o}const Y="anim:";function ls(e,t){return`${Y}${e}:${t}`}function _e(e){if(!e.startsWith(Y))return null;const t=e.slice(Y.length),n=t.indexOf(":");if(n<0)return null;const s=t.slice(0,n),o=t.slice(n+1);return!(s in R)||o.length===0?null:{species:s,name:o}}function ut(e){return e==="idle"?0:e==="walk"||e==="run"?50:e==="attack"?150:300}function pt(e){return e==="idle"?1:e==="walk"||e==="run"?5:e==="attack"?15:25}function us(e,t){return lt(e).map(n=>({id:ls(e,n),species:e,name:n,priceBits:ut(n),levelRequired:pt(n),owned:n==="idle"||t.has(n)})).sort(ps)}function ps(e,t){return e.name==="idle"&&t.name!=="idle"?-1:t.name==="idle"&&e.name!=="idle"?1:e.priceBits!==t.priceBits?e.priceBits-t.priceBits:e.name.localeCompare(t.name)}function ds(){const e=f(),t=e.prepare("SELECT item_id FROM unlocks WHERE category = 'species'").all().map(o=>o.item_id.slice(8)).filter(o=>o in R),n=new Map;for(const o of e.prepare("SELECT item_id FROM unlocks WHERE category = 'animation'").all()){const i=_e(o.item_id);if(!i)continue;let a=n.get(i.species);a||(a=new Set,n.set(i.species,a)),a.add(i.name)}const s={};for(const o of t)s[o]=us(o,n.get(o)??new Set);return s}function De(e){const t=f().prepare("SELECT item_id FROM unlocks WHERE category = 'animation' AND item_id LIKE ?").all(`${Y}${e}:%`),n=new Set;for(const s of t){const o=_e(s.item_id);o&&n.add(o.name)}return n}function fs(e){const t=_e(e);if(!t||!f().prepare("SELECT item_id FROM unlocks WHERE category = 'species' AND item_id = ?").get(`species:${t.species}`)||!lt(t.species).includes(t.name))return null;const s=ut(t.name);let o="common";return s>=300?o="rare":s>=150?o="uncommon":s>0&&(o="common"),{id:e,kind:"animation",species:t.species,name:t.name,priceBits:s,levelRequired:pt(t.name),label:ms(t.species,t.name),tier:o}}function ms(e,t){const n=R[e].label,s=t.charAt(0).toUpperCase()+t.slice(1);return`${n} — ${s}`}const gs=Object.entries(R).map(([e,t])=>({id:`species:${e}`,kind:"species",tier:t.tier,priceBits:t.priceBits,label:t.label,species:e})),Es=[{id:"bit_multiplier_2x",kind:"upgrade",tier:"rare",priceBits:500,label:"2× Bits",description:"Doubles bits earned from messages and tokens. Permanent.",effect:"bit_multiplier_2x"}],dt=[...gs,...Es];function hs(e){return dt.find(t=>t.id===e)}function _s(e){return e.startsWith(Y)?fs(e):hs(e)??null}function ys(e){const t=_s(e);if(!t)return{error:"unknown-item"};const n=f();let s={error:"unknown-item"};return n.transaction(()=>{if(n.prepare("SELECT item_id FROM unlocks WHERE item_id = ?").get(t.id)){s={error:"already-owned"};return}const a=n.prepare("SELECT level, bits FROM pet WHERE id = 1").get();if(!a)throw new Error("pet row missing");if(t.kind==="animation"&&a.level<t.levelRequired){s={error:"level-locked",required:t.levelRequired,current:a.level};return}if(a.bits<t.priceBits){s={error:"insufficient",bits:a.bits,price:t.priceBits};return}n.prepare("UPDATE pet SET bits = bits - ? WHERE id = 1").run(t.priceBits),n.prepare("INSERT INTO unlocks (item_id, category, acquired_via, acquired_at) VALUES (?, ?, 'shop', ?)").run(t.id,t.kind,Date.now()),s={ok:!0,itemId:t.id,category:t.kind,bitsRemaining:a.bits-t.priceBits,pricePaid:t.priceBits}})(),"ok"in s&&X(),s}const J=[{id:"bits_small",kind:"bits",tier:"common",weight:40,amount:25,label:"+25 bits"},{id:"bits_medium",kind:"bits",tier:"uncommon",weight:18,amount:75,label:"+75 bits"},{id:"bits_large",kind:"bits",tier:"rare",weight:6,amount:200,label:"+200 bits"},{id:"bits_jackpot",kind:"bits",tier:"legendary",weight:1,amount:1e3,label:"+1000 bits"},{id:"xp_small",kind:"xp",tier:"uncommon",weight:12,amount:50,label:"+50 XP"},{id:"xp_large",kind:"xp",tier:"rare",weight:3,amount:200,label:"+200 XP"},{id:"species_token",kind:"species_token",tier:"legendary",weight:1,label:"New Species!"}],xe=500;function bs(e=Math.random){const t=J.reduce((s,o)=>s+o.weight,0);let n=e()*t;for(const s of J)if(n-=s.weight,n<0)return s;return J[J.length-1]}function Ts(e){const t=f();let n={error:"no-spins"};return t.transaction(()=>{const o=t.prepare("SELECT spins_available FROM spin_state WHERE id = 1").get();if(!o||o.spins_available<=0){n={error:"no-spins"};return}t.prepare("UPDATE spin_state SET spins_available = spins_available - 1 WHERE id = 1").run();const i=o.spins_available-1,a=bs(e),c={id:a.id,kind:a.kind,tier:a.tier,label:a.label};if(a.kind==="bits"){t.prepare("UPDATE pet SET bits = bits + ? WHERE id = 1").run(a.amount),n={reward:c,applied:{kind:"bits",amount:a.amount},spinsRemaining:i};return}if(a.kind==="xp"){const m=t.prepare("SELECT level, xp FROM pet WHERE id = 1").get();if(!m)throw new Error("pet row missing");let y=m.level,v=m.xp+a.amount,A=0;for(;v>=Q.xpForLevel(y)&&(v-=Q.xpForLevel(y),y+=1,A+=1,!(A>100)););t.prepare("UPDATE pet SET level = ?, xp = ? WHERE id = 1").run(y,v),n={reward:c,applied:{kind:"xp",amount:a.amount,levelsGained:A},spinsRemaining:i};return}const r=t.prepare("SELECT item_id FROM unlocks WHERE category = 'species'").all(),l=new Set(r.map(m=>m.item_id.slice(8))),p=Object.keys(R).filter(m=>!l.has(m));if(p.length===0){t.prepare("UPDATE pet SET bits = bits + ? WHERE id = 1").run(xe),n={reward:c,applied:{kind:"bits",amount:xe,consolationFor:"species_token"},spinsRemaining:i};return}const d=Math.random,g=Math.min(p.length-1,Math.floor(d()*p.length)),b=p[g];t.prepare("INSERT OR IGNORE INTO unlocks (item_id, category, acquired_via, acquired_at) VALUES (?, 'species', 'spin', ?)").run(`species:${b}`,Date.now()),n={reward:c,applied:{kind:"species",species:b},spinsRemaining:i}})(),"error"in n||X(),n}function Ss(){u.ipcMain.handle("codeling:getPet",()=>M()),u.ipcMain.handle("codeling:getSpinState",()=>Tn()),u.ipcMain.handle("codeling:getStats",()=>Sn()),u.ipcMain.handle("codeling:getSprites",(e,t)=>{const n=De(t);return cs(t,n)}),u.ipcMain.handle("codeling:getAnimationsCatalog",()=>ds()),u.ipcMain.handle("codeling:getHomeAnimation",(e,t)=>t in R?gn(t):"idle"),u.ipcMain.handle("codeling:setHomeAnimation",(e,t,n)=>t in R?typeof n!="string"||n.length===0?{error:"not-owned"}:n!=="idle"&&!De(t).has(n)?{error:"not-owned"}:(En(t,n),S(),{ok:!0,name:n}):{error:"not-owned"}),u.ipcMain.handle("codeling:getUnlocks",()=>vn()),u.ipcMain.handle("codeling:getShopItems",()=>{const e=new Set(f().prepare("SELECT item_id FROM unlocks").all().map(t=>t.item_id));return dt.filter(t=>!e.has(t.id)).map(t=>({id:t.id,kind:t.kind,priceBits:t.priceBits,label:t.label,description:t.description,tier:t.tier}))}),u.ipcMain.handle("codeling:spin",()=>{const e=Ts();return"error"in e||S(),e}),u.ipcMain.handle("codeling:purchase",(e,t)=>{const n=ys(t);return"ok"in n&&S(),n}),u.ipcMain.handle("codeling:setSpinThreshold",(e,t)=>{const n=typeof t=="number"?t:Number(t);try{const s=_n(n);return S(),{ok:!0,value:s}}catch(s){const o=s.message;if(o==="not-integer"||o==="out-of-range")return{error:o,min:Ge,max:qe};throw s}}),u.ipcMain.handle("codeling:getEconomyRules",()=>({rules:ee(),bounds:pe})),u.ipcMain.handle("codeling:setEconomyRule",(e,t,n)=>{const s=typeof n=="number"?n:Number(n);try{const o=On(t,s);return S(),{ok:!0,rules:o}}catch(o){const i=o.message;if(i==="unknown-key"||i==="not-integer"||i==="out-of-range")return{error:i,bounds:pe[t]};throw o}}),u.ipcMain.handle("codeling:resetEconomyRules",()=>{const e=An();return S(),{rules:e}}),u.ipcMain.handle("codeling:setActiveSpecies",(e,t)=>{const n=String(t);if(!(n in R))return{error:"not-owned"};const s=hn(n);return"ok"in s&&(L.emit("pet:species-changed",{species:n}),S()),s}),u.ipcMain.handle("codeling:resetSave",()=>{yn(),L.emit("pet:reset");try{L.emit("pet:renamed",{name:M().name})}catch{}return S(),{ok:!0}}),u.ipcMain.handle("codeling:getReceiverInfo",()=>({http:"http://127.0.0.1:4318",grpc:"http://127.0.0.1:4317"})),u.ipcMain.handle("codeling:getTelemetryEnabled",()=>({enabled:ot(),running:he()})),u.ipcMain.handle("codeling:setTelemetryEnabled",async(e,t)=>{const n=await Kn(!!t);return S(),{enabled:!!t,running:n}}),u.ipcMain.handle("codeling:getAchievements",()=>wn()),u.ipcMain.handle("codeling:getStreak",()=>Xe()),u.ipcMain.handle("codeling:getAutoLaunch",()=>u.app.getLoginItemSettings().openAtLogin),u.ipcMain.handle("codeling:setAutoLaunch",(e,t)=>(u.app.setLoginItemSettings({openAtLogin:!!t}),u.app.getLoginItemSettings().openAtLogin)),u.ipcMain.handle("codeling:openPopout",()=>Qn()),u.ipcMain.handle("codeling:closePopout",()=>(Zn(),{ok:!0})),u.ipcMain.handle("codeling:isPopoutOpen",()=>de()),u.ipcMain.handle("codeling:exportSave",()=>ss()),u.ipcMain.handle("codeling:importSave",async()=>{const e=await rs();if("ok"in e){L.emit("pet:reset");try{L.emit("pet:renamed",{name:M().name})}catch{}S()}return e}),u.ipcMain.handle("codeling:renamePet",(e,t)=>{if(typeof t!="string")return{error:"empty-name"};try{const n=bn(t);return L.emit("pet:renamed",{name:n}),S(),{ok:!0,name:n}}catch(n){const s=n.message;if(s==="empty-name"||s==="name-too-long")return{error:s};throw n}})}const vs=40,$e=4,ws={wizard:.55,slime:.85,flying_eye:.9,bat:.7,mimic:.65,evil_wizard:.5,fire_worm:.8,martial_hero:.5,martial_hero_2:.5,apprentice_wizard:.5,goblin:.55,skeleton:.5,mushroom:.65,rat:.7};u.app.requestSingleInstanceLock()||(u.app.quit(),process.exit(0));function ft(){return u.app.isPackaged?E.join(process.resourcesPath,"assets"):E.join(u.app.getAppPath(),"assets")}function Pe(e,t=16,n=1){const{width:s,height:o}=e.getSize();if(s===0||o===0)return e;const i=e.toBitmap();let a=s,c=o,r=-1,l=-1;for(let m=0;m<o;m++)for(let y=0;y<s;y++)(i[(m*s+y)*4+3]??0)>t&&(y<a&&(a=y),y>r&&(r=y),m<c&&(c=m),m>l&&(l=m));if(r<0)return e;const p=Math.max(0,a-n),d=Math.max(0,c-n),g=Math.min(s-p,r-a+1+2*n),b=Math.min(o-d,l-c+1+2*n);return e.crop({x:p,y:d,width:g,height:b})}function Rs(e,t){const{width:n,height:s}=e.getSize();if(n===0||s===0)return e;const o=Math.max(1,Math.round(s*t));return e.crop({x:0,y:0,width:n,height:o})}function mt(e,t){const n=ws[t]??.55,s=Pe(Rs(Pe(e),n));if(process.platform!=="win32")return s;const{width:o,height:i}=s.getSize(),a=vs/Math.max(o,i,1);return s.resize({width:Math.max(1,Math.round(o*a)),height:Math.max(1,Math.round(i*a)),quality:"best"})}function gt(e){return E.join(ft(),"sprites",e)}function Fe(){try{const n=M(),s=gt(n.species),o=E.join(s,"rotations","south.png");if(T.existsSync(o))return mt(u.nativeImage.createFromPath(o),n.species)}catch{}const e=process.platform==="darwin"?"tray-icon-Template.png":"tray-icon.png",t=E.join(ft(),e);return T.existsSync(t)?u.nativeImage.createFromPath(t):u.nativeImage.createEmpty()}function ks(e){const t=E.join(gt(e),"animations");if(!T.existsSync(t))return[];const n=T.readdirSync(t).find(i=>/idle|breath/i.test(i));if(!n)return[];const s=E.join(t,n,"south");return T.existsSync(s)?T.readdirSync(s).filter(i=>i.toLowerCase().endsWith(".png")).sort().map(i=>mt(u.nativeImage.createFromPath(E.join(s,i)),e)):[]}function je(){return`file://${E.join(__dirname,"../renderer/main_window/index.html")}`}function He(){return E.join(__dirname,"preload.js")}async function Ls(){var c;await u.app.whenReady(),process.platform==="darwin"&&((c=u.app.dock)==null||c.hide()),f(),X(!0),Je(),Ss(),ot()?rt():console.log("[otel] receivers not started — telemetry disabled in Settings"),pn();let e="Codeling";try{e=`Codeling — ${M().name}`}catch{}const t=_t.menubar({index:je(),icon:Fe(),tooltip:e,showDockIcon:!1,preloadWindow:!0,browserWindow:{width:380,height:560,transparent:!1,resizable:!1,webPreferences:{preload:He(),contextIsolation:!0,nodeIntegration:!1,sandbox:!1}}});t.on("ready",()=>{var r;console.log("[codeling] menubar ready"),u.app.isPackaged||(r=t.window)==null||r.webContents.openDevTools({mode:"detach"}),o(),Jn({mb:t,rendererUrl:je(),preloadPath:He()})}),t.on("after-create-window",()=>{var r;u.app.isPackaged||(r=t.window)==null||r.webContents.openDevTools({mode:"detach"})});let n=null,s=null;function o(){n&&(clearInterval(n),n=null);let r;try{r=M().species}catch{return}t.tray&&!t.tray.isDestroyed()&&t.tray.setImage(Fe()),s=r;const l=ks(r);if(l.length<=1)return;console.log(`[tray] animating ${l.length} idle frames at ${$e} fps (${r})`);let p=0;n=setInterval(()=>{if(!t.tray||t.tray.isDestroyed()){n&&(clearInterval(n),n=null);return}t.tray.setImage(l[p%l.length]),p++},Math.round(1e3/$e))}const a=setInterval(()=>{let r;try{r=M().species}catch{return}s!==null&&r!==s&&(console.log(`[tray] species drifted ${s} → ${r}; refreshing`),o())},5e3);typeof a.unref=="function"&&a.unref(),L.on("pet:species-changed",r=>{console.log(`[tray] active species changed → ${r.species}; refreshing`),o()}),L.on("pet:reset",()=>{console.log("[tray] save reset; refreshing tray"),o()}),L.on("pet:renamed",r=>{t.tray&&!t.tray.isDestroyed()&&t.tray.setToolTip(`Codeling — ${r.name}`)}),L.on("achievement:earned",r=>{console.log(`[achievement] earned ${r.id}: ${r.label}`),u.Notification.isSupported()&&new u.Notification({title:`Achievement: ${r.label}`,body:r.description,silent:!1}).show()}),u.app.on("before-quit",()=>{n&&clearInterval(n),clearInterval(a)})}u.app.on("before-quit",()=>{It()});Ls().catch(e=>{console.error("[codeling] bootstrap failed",e),u.app.quit()});
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tyler Dodd
|
|
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,237 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# Codeling
|
|
4
|
+
|
|
5
|
+
**A gamified pet companion for [Claude Code](https://claude.com/claude-code).**
|
|
6
|
+
The more you code, the more your pet grows.
|
|
7
|
+
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
[](https://github.com/tdodd777/Codeling/releases)
|
|
10
|
+
[](https://www.electronjs.org/)
|
|
11
|
+
[](https://opentelemetry.io/)
|
|
12
|
+
|
|
13
|
+
<br />
|
|
14
|
+
|
|
15
|
+
<img src="assets/sprites/wizard/rotations/south.png" width="80" alt="wizard" />
|
|
16
|
+
<img src="assets/sprites/slime/rotations/south.png" width="80" alt="slime" />
|
|
17
|
+
<img src="assets/sprites/flying_eye/rotations/south.png" width="80" alt="flying eye" />
|
|
18
|
+
<img src="assets/sprites/mimic/rotations/south.png" width="80" alt="mimic" />
|
|
19
|
+
<img src="assets/sprites/bat/rotations/south.png" width="80" alt="bat" />
|
|
20
|
+
<img src="assets/sprites/fire_worm/rotations/south.png" width="80" alt="fire worm" />
|
|
21
|
+
<img src="assets/sprites/mushroom/rotations/south.png" width="80" alt="mushroom" />
|
|
22
|
+
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<br />
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Overview
|
|
31
|
+
|
|
32
|
+
Codeling lives in your **menu bar** (macOS) or **system tray** (Windows / Linux) and turns your Claude Code sessions into XP. Every prompt you send feeds your pet. Level up, spin the wheel, unlock new species, decorate your shelf.
|
|
33
|
+
|
|
34
|
+
Under the hood it's a love letter to indie pixel-pet games (Tamagotchi, Neopets, the desktop companions of the late 90s), wired up to an OpenTelemetry pipeline that consumes Claude Code's emitted metrics in real time. No accounts, no cloud, everything lives on-device.
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
|
|
38
|
+
- **14 species to collect**: hand-picked pixel art with full idle / run / attack / hurt / death animations
|
|
39
|
+
- **OTLP telemetry**: local OpenTelemetry receivers (HTTP + gRPC) parse Claude Code's metric stream and convert it into XP and bits
|
|
40
|
+
- **Spin wheel + shop**: every 50 messages earns a spin; spend bits on new species and animation unlocks
|
|
41
|
+
- **Achievements + daily streaks**: milestone notifications and a daily-summary popup
|
|
42
|
+
- **Live-tunable economy**: XP / bit / spin rules editable from the Settings panel without a restart
|
|
43
|
+
- **Save export/import**: full snapshot in/out as a single JSON file
|
|
44
|
+
- **100% local**: no analytics, no account, no cloud sync. SQLite on your disk.
|
|
45
|
+
|
|
46
|
+
## Quickstart
|
|
47
|
+
|
|
48
|
+
Requires **Node 18+** and a working [Claude Code](https://claude.com/claude-code) install.
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npx codeling install
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
That one command:
|
|
55
|
+
|
|
56
|
+
1. Downloads the right installer for your OS from the latest [GitHub Release](https://github.com/tdodd777/Codeling/releases) and runs it (`.exe` on Windows, `.dmg` on macOS, `.deb` / `.rpm` on Linux)
|
|
57
|
+
2. Sets the user-scope OTEL env vars so every Claude Code session feeds Codeling's receiver
|
|
58
|
+
3. Installs a `Stop` hook in `~/.claude/settings.json` as a per-turn message-count backup
|
|
59
|
+
|
|
60
|
+
Restart your shell (IDEs too) so the env vars propagate, then send a message in Claude Code and watch your pet level up.
|
|
61
|
+
|
|
62
|
+
> **First-launch warning**: until paid signing certs are wired, an "unidentified developer" / "publisher unknown" prompt appears on first launch. Dismiss it once and subsequent launches are silent.
|
|
63
|
+
|
|
64
|
+
Need staged installs? Use `--skip-app`, `--skip-otel`, or `--skip-hook`.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npx codeling status # show env vars + Stop hook + platform
|
|
68
|
+
npx codeling uninstall # remove telemetry + Stop hook
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Screenshots
|
|
72
|
+
|
|
73
|
+
<!-- TODO: drop UI screenshots into docs/screenshots/ as you capture them -->
|
|
74
|
+
|
|
75
|
+
<table>
|
|
76
|
+
<tr>
|
|
77
|
+
<td align="center"><img src="docs/screenshots/home.png" alt="Home tab" width="280" /><br /><sub><b>Home</b>: pet, level, XP, bits, spin progress</sub></td>
|
|
78
|
+
<td align="center"><img src="docs/screenshots/shop.png" alt="Shop tab" width="280" /><br /><sub><b>Shop</b>: spend bits on species & animations</sub></td>
|
|
79
|
+
<td align="center"><img src="docs/screenshots/stats.png" alt="Stats tab" width="280" /><br /><sub><b>Stats</b>: tokens, streaks, achievements</sub></td>
|
|
80
|
+
</tr>
|
|
81
|
+
</table>
|
|
82
|
+
|
|
83
|
+
## The roster
|
|
84
|
+
|
|
85
|
+
All 14 species ship bundled with the installer. No downloads, no extra setup. Pick yours from the Shop once you've unlocked it.
|
|
86
|
+
|
|
87
|
+
<table>
|
|
88
|
+
<tr>
|
|
89
|
+
<td align="center"><img src="assets/sprites/wizard/rotations/south.png" width="64" /><br /><sub><b>Wizard</b></sub></td>
|
|
90
|
+
<td align="center"><img src="assets/sprites/slime/rotations/south.png" width="64" /><br /><sub><b>Slime</b></sub></td>
|
|
91
|
+
<td align="center"><img src="assets/sprites/flying_eye/rotations/south.png" width="64" /><br /><sub><b>Flying Eye</b></sub></td>
|
|
92
|
+
<td align="center"><img src="assets/sprites/bat/rotations/south.png" width="64" /><br /><sub><b>Bat</b></sub></td>
|
|
93
|
+
<td align="center"><img src="assets/sprites/mimic/rotations/south.png" width="64" /><br /><sub><b>Mimic</b></sub></td>
|
|
94
|
+
<td align="center"><img src="assets/sprites/evil_wizard/rotations/south.png" width="64" /><br /><sub><b>Evil Wizard</b></sub></td>
|
|
95
|
+
<td align="center"><img src="assets/sprites/fire_worm/rotations/south.png" width="64" /><br /><sub><b>Fire Worm</b></sub></td>
|
|
96
|
+
</tr>
|
|
97
|
+
<tr>
|
|
98
|
+
<td align="center"><img src="assets/sprites/martial_hero/rotations/south.png" width="64" /><br /><sub><b>Martial Hero</b></sub></td>
|
|
99
|
+
<td align="center"><img src="assets/sprites/martial_hero_2/rotations/south.png" width="64" /><br /><sub><b>Martial Hero 2</b></sub></td>
|
|
100
|
+
<td align="center"><img src="assets/sprites/apprentice_wizard/rotations/south.png" width="64" /><br /><sub><b>Apprentice Wizard</b></sub></td>
|
|
101
|
+
<td align="center"><img src="assets/sprites/goblin/rotations/south.png" width="64" /><br /><sub><b>Goblin</b></sub></td>
|
|
102
|
+
<td align="center"><img src="assets/sprites/skeleton/rotations/south.png" width="64" /><br /><sub><b>Skeleton</b></sub></td>
|
|
103
|
+
<td align="center"><img src="assets/sprites/mushroom/rotations/south.png" width="64" /><br /><sub><b>Mushroom</b></sub></td>
|
|
104
|
+
<td align="center"><img src="assets/sprites/rat/rotations/south.png" width="64" /><br /><sub><b>Rat</b></sub></td>
|
|
105
|
+
</tr>
|
|
106
|
+
</table>
|
|
107
|
+
|
|
108
|
+
## How it works
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
Claude Code
|
|
112
|
+
│
|
|
113
|
+
│ OTLP (HTTP :4318 or gRPC :4317)
|
|
114
|
+
▼
|
|
115
|
+
┌──────────────────────────────────────┐
|
|
116
|
+
│ Codeling receivers │
|
|
117
|
+
│ ↓ │
|
|
118
|
+
│ Aggregator │
|
|
119
|
+
│ • claude_code.token.usage → tokens
|
|
120
|
+
│ • event.name=user_prompt → messages
|
|
121
|
+
│ ↓ │
|
|
122
|
+
│ Economy → XP / bits / spins │
|
|
123
|
+
│ ↓ │
|
|
124
|
+
│ SQLite (better-sqlite3) │
|
|
125
|
+
│ ↓ │
|
|
126
|
+
│ React panel (menubar tray) │
|
|
127
|
+
└──────────────────────────────────────┘
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- **Telemetry**: Codeling runs OTLP receivers on `127.0.0.1:4318` (HTTP) and `127.0.0.1:4317` (gRPC). Claude Code emits to either when the OTEL env vars are set; HTTP is the default.
|
|
131
|
+
- **Economy**: `src/main/economy.ts` converts ingested events into XP and bits. Defaults live in `ECONOMY_RULE_DEFAULTS`; overrides go in the `meta` table and are editable live from the Settings tab.
|
|
132
|
+
- **State**: all on-device in SQLite:
|
|
133
|
+
- Windows: `%APPDATA%\Codeling\codeling.db`
|
|
134
|
+
- macOS: `~/Library/Application Support/Codeling/codeling.db`
|
|
135
|
+
- Linux: `~/.config/Codeling/codeling.db`
|
|
136
|
+
|
|
137
|
+
The Stop hook in `~/.claude/settings.json` is a backup per-turn tally. If an OTLP event drops, the message count stays correct.
|
|
138
|
+
|
|
139
|
+
<details>
|
|
140
|
+
<summary><b>Manual env vars</b> (if you'd rather configure telemetry yourself)</summary>
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
CLAUDE_CODE_ENABLE_TELEMETRY=1
|
|
144
|
+
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
|
|
145
|
+
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
|
146
|
+
OTEL_METRICS_EXPORTER=otlp
|
|
147
|
+
OTEL_LOGS_EXPORTER=otlp
|
|
148
|
+
OTEL_METRIC_EXPORT_INTERVAL=10000
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
</details>
|
|
152
|
+
|
|
153
|
+
## Uninstall
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
npx codeling uninstall # removes telemetry env vars + Stop hook
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The app itself uninstalls through the OS:
|
|
160
|
+
|
|
161
|
+
- **Windows**: *Add or Remove Programs*
|
|
162
|
+
- **macOS**: drag to Trash
|
|
163
|
+
- **Linux**: `apt remove codeling` or `rpm -e codeling`
|
|
164
|
+
|
|
165
|
+
## Roadmap
|
|
166
|
+
|
|
167
|
+
The honest list — stuff that's scrappy today and stuff worth adding next:
|
|
168
|
+
|
|
169
|
+
- **Better spin reveal animation.** Current spin is a 900ms tier-cycle into a static toast. A real slot-machine / wheel-of-fortune treatment with deceleration and tier-aware payoff would land harder.
|
|
170
|
+
- **More consistent sprite art.** The 14 species ship from three different artists in different aesthetic registers — pixel size, palette, line weight all vary. A unified-style pass (or a single-artist v2 roster) would tighten the visual identity.
|
|
171
|
+
- **Nicer tray animation.** 4 FPS idle frames at small sizes read as static-with-a-hiccup. Higher frame rate, motion smoothing, or activity-triggered reactions (a wiggle when XP comes in) would make the tray feel more alive.
|
|
172
|
+
- **More achievements.** 16 today across engagement / progression / collection / cost / streak. More categories (long-session bonuses, late-night coders, multi-day-burst sprints) would deepen the loop.
|
|
173
|
+
- **Other coding tools.** Today the pipeline is wired for Claude Code's OTLP signal. Codex and other agentic coding tools that emit OTLP could feed the same XP/bits economy with adapter work in the aggregator.
|
|
174
|
+
|
|
175
|
+
## Development
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
git clone https://github.com/tdodd777/Codeling.git
|
|
179
|
+
cd Codeling
|
|
180
|
+
npm install
|
|
181
|
+
npm run setup # telemetry + Stop hook only (same as install --skip-app)
|
|
182
|
+
npm start # Forge dev: Vite HMR + hot main-process reload
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`npm start` is the full dev loop: Vite HMR for the renderer, hot main-process reload on save. Type `rs` in the terminal to force a restart.
|
|
186
|
+
|
|
187
|
+
| Script | What |
|
|
188
|
+
|---|---|
|
|
189
|
+
| `npm start` | Forge dev: Vite HMR for renderer + hot main reload |
|
|
190
|
+
| `npm run lint` | TypeScript type check (`tsc --noEmit`) |
|
|
191
|
+
| `npm test` | Run vitest (pure-logic suites); `npm run test:watch` for watch mode |
|
|
192
|
+
| `npm run package` | Forge package: unpacked binary |
|
|
193
|
+
| `npm run make` | Forge make: full installers (Squirrel / DMG / DEB / RPM) in `out/make/` |
|
|
194
|
+
| `GITHUB_TOKEN=… npm run release` | Forge publish: uploads installers to GitHub Releases as a draft |
|
|
195
|
+
|
|
196
|
+
### Project layout
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
src/
|
|
200
|
+
├── main/ Electron main process
|
|
201
|
+
│ ├── db/ SQLite client + schema + repos
|
|
202
|
+
│ ├── otel/ OTLP receivers, decoders, aggregator
|
|
203
|
+
│ ├── economy.ts XP / Bits / level / spin rules
|
|
204
|
+
│ ├── sprites.ts PixelLab-format sprite manifest builder
|
|
205
|
+
│ └── index.ts Entry: menubar setup, lifecycle, tray animation
|
|
206
|
+
├── preload/ contextBridge surface (window.codeling.*)
|
|
207
|
+
├── renderer/ React panel (Home / Shop / Stats / Settings)
|
|
208
|
+
└── shared/ IPC contract types
|
|
209
|
+
|
|
210
|
+
assets/sprites/<species>/ Per-species sprite folders (bundled)
|
|
211
|
+
scripts/ Install/uninstall CLI + sprite slicer
|
|
212
|
+
proto/ Vendored OTLP collector .proto files
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Contributing
|
|
216
|
+
|
|
217
|
+
Contributions welcome. Common paths:
|
|
218
|
+
|
|
219
|
+
- **Add a species.** Drop a sprite folder under `assets/sprites/<species>/` matching the existing layout (`rotations/south.png` as the static fallback, `animations/<Name>/south/frame_NNN.png` for each animation — the folder name is matched against keywords: `idle`/`breath`, `run`, `walk`, `attack`). Add the key to the `Species` union and `SPECIES_CATALOG` in `src/shared/types.ts`; optionally tune the head-crop fraction in `TRAY_HEAD_FRACTION` (`src/main/index.ts`). Bar for inclusion: rich animation (≥6 frames per anim, ≥3 distinct animations). Acceptable licenses: **CC0**, **CC-BY** (with attribution in `assets/sources/<creator>/NOTICE.md`), **MIT**. Reject anything with **SA / NC / ND / GPL** clauses — copyleft / non-commercial restrictions are incompatible with this repo's MIT license. LuizMelo packs ship one PNG per animation as a horizontal strip; slice via `scripts/luizmelo-slice.py` (extend the `SPECIES` dict and re-run).
|
|
220
|
+
- **Tune the economy.** Defaults in `src/main/economy.ts`. Open an issue or PR with the proposed delta and the reasoning.
|
|
221
|
+
- **Polish targets.** Spin animation, tray animation, more achievements, support for additional coding tools — see *Roadmap* above.
|
|
222
|
+
|
|
223
|
+
For larger changes, open an issue first. The dated decision log in [`DIRECTION.md`](DIRECTION.md) captures the *why* behind current choices.
|
|
224
|
+
|
|
225
|
+
## Acknowledgments
|
|
226
|
+
|
|
227
|
+
Codeling builds on the work of pixel artists who release under permissive licenses. Every sprite in the roster is either CC0 or generated from pixellab:
|
|
228
|
+
|
|
229
|
+
- **[LuizMelo](https://luizmelo.itch.io/)**: 12 of 14 species (Flying Eye, Bat, Mimic, Evil Wizard, Fire Worm, Martial Hero, Martial Hero 2, Apprentice Wizard, Goblin, Skeleton, Mushroom, Rat). Carries the CC0-with-rich-animation niche on itch.io.
|
|
230
|
+
- **[rvros](https://rvros.itch.io/pixel-art-animated-slime)**: the slime.
|
|
231
|
+
- **[PixelLab](https://pixellab.ai/)**: the original wizard.
|
|
232
|
+
|
|
233
|
+
Per-source license details live in `NOTICE.md` files under `assets/sources/`.
|
|
234
|
+
|
|
235
|
+
## License
|
|
236
|
+
|
|
237
|
+
[MIT](LICENSE) © Tyler Dodd
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "codeling",
|
|
3
|
+
"productName": "Codeling",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Gamified Claude Code companion that lives in your menu bar.",
|
|
6
|
+
"main": ".vite/build/main.js",
|
|
7
|
+
"private": false,
|
|
8
|
+
"author": {
|
|
9
|
+
"name": "Tyler Dodd",
|
|
10
|
+
"email": "tdodd777@users.noreply.github.com",
|
|
11
|
+
"url": "https://github.com/tdodd777"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"homepage": "https://github.com/tdodd777/Codeling",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/tdodd777/Codeling.git"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"codeling": "scripts/codeling-cli.mjs"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"scripts/codeling-cli.mjs",
|
|
27
|
+
"scripts/install-telemetry.ps1",
|
|
28
|
+
"scripts/install-telemetry.sh",
|
|
29
|
+
"scripts/install-stop-hook.mjs"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"start": "electron-forge start",
|
|
33
|
+
"package": "electron-forge package",
|
|
34
|
+
"make": "electron-forge make",
|
|
35
|
+
"release": "electron-forge publish",
|
|
36
|
+
"lint": "tsc --noEmit",
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"test:watch": "vitest",
|
|
39
|
+
"setup": "node ./scripts/codeling-cli.mjs install --skip-app",
|
|
40
|
+
"setup:uninstall": "node ./scripts/codeling-cli.mjs uninstall",
|
|
41
|
+
"setup:status": "node ./scripts/codeling-cli.mjs status",
|
|
42
|
+
"dev:unlock-all": "node ./scripts/dev-unlock-all.mjs",
|
|
43
|
+
"stop-hook:install": "node ./scripts/install-stop-hook.mjs install",
|
|
44
|
+
"stop-hook:uninstall": "node ./scripts/install-stop-hook.mjs uninstall",
|
|
45
|
+
"stop-hook:status": "node ./scripts/install-stop-hook.mjs status"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@grpc/grpc-js": "^1.12.2",
|
|
49
|
+
"@grpc/proto-loader": "^0.7.13",
|
|
50
|
+
"better-sqlite3": "^11.5.0",
|
|
51
|
+
"express": "^4.21.1",
|
|
52
|
+
"menubar": "^9.5.2",
|
|
53
|
+
"protobufjs": "^7.4.0",
|
|
54
|
+
"react": "^18.3.1",
|
|
55
|
+
"react-dom": "^18.3.1",
|
|
56
|
+
"update-electron-app": "^3.2.0"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@electron-forge/cli": "^7.5.0",
|
|
60
|
+
"@electron-forge/maker-deb": "^7.5.0",
|
|
61
|
+
"@electron-forge/maker-dmg": "^7.11.1",
|
|
62
|
+
"@electron-forge/maker-rpm": "^7.5.0",
|
|
63
|
+
"@electron-forge/maker-squirrel": "^7.5.0",
|
|
64
|
+
"@electron-forge/plugin-vite": "^7.5.0",
|
|
65
|
+
"@electron-forge/publisher-github": "^7.11.1",
|
|
66
|
+
"@types/better-sqlite3": "^7.6.11",
|
|
67
|
+
"@types/express": "^5.0.0",
|
|
68
|
+
"@types/node": "^22.7.0",
|
|
69
|
+
"@types/react": "^18.3.11",
|
|
70
|
+
"@types/react-dom": "^18.3.0",
|
|
71
|
+
"@vitejs/plugin-react": "^4.3.2",
|
|
72
|
+
"electron": "^33.0.0",
|
|
73
|
+
"typescript": "~5.6.2",
|
|
74
|
+
"vite": "^5.4.8",
|
|
75
|
+
"vitest": "^4.1.5"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// Codeling CLI — single entrypoint for setup tasks. Wraps the platform-specific
|
|
4
|
+
// telemetry installer and the cross-platform Stop hook installer so users run
|
|
5
|
+
// one command instead of three.
|
|
6
|
+
//
|
|
7
|
+
// Subcommands:
|
|
8
|
+
// install Download + install the app, then telemetry + Stop hook.
|
|
9
|
+
// uninstall Reverse of install (telemetry + Stop hook; app removal is manual).
|
|
10
|
+
// status Show what's installed.
|
|
11
|
+
//
|
|
12
|
+
// Usage:
|
|
13
|
+
// node scripts/codeling-cli.mjs [install|uninstall|status]
|
|
14
|
+
//
|
|
15
|
+
// After publishing to npm:
|
|
16
|
+
// npx codeling install
|
|
17
|
+
//
|
|
18
|
+
// Flags:
|
|
19
|
+
// --skip-app Skip the binary download (use when running from a cloned repo).
|
|
20
|
+
// --skip-otel Skip the telemetry env-var installer.
|
|
21
|
+
// --skip-hook Skip the Stop hook installer.
|
|
22
|
+
|
|
23
|
+
import { spawn } from 'node:child_process';
|
|
24
|
+
import { createWriteStream, existsSync, mkdirSync, statSync } from 'node:fs';
|
|
25
|
+
import { unlink } from 'node:fs/promises';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
32
|
+
|
|
33
|
+
const GITHUB_OWNER = 'tdodd777';
|
|
34
|
+
const GITHUB_REPO = 'Codeling';
|
|
35
|
+
const LATEST_RELEASE_URL = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/latest`;
|
|
36
|
+
|
|
37
|
+
// ---- Platform detection -----------------------------------------------------
|
|
38
|
+
|
|
39
|
+
function isWindows() {
|
|
40
|
+
return process.platform === 'win32';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isMac() {
|
|
44
|
+
return process.platform === 'darwin';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isLinux() {
|
|
48
|
+
return process.platform === 'linux';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---- Subprocess runner ------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
// Runs a child process with stdout/stderr piped through to our own streams.
|
|
54
|
+
// Resolves with the exit code; callers decide what to do on non-zero.
|
|
55
|
+
function run(cmd, args, opts = {}) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const child = spawn(cmd, args, {
|
|
58
|
+
cwd: opts.cwd ?? REPO_ROOT,
|
|
59
|
+
stdio: opts.stdio ?? 'inherit',
|
|
60
|
+
shell: opts.shell ?? false,
|
|
61
|
+
env: { ...process.env, ...(opts.env ?? {}) },
|
|
62
|
+
});
|
|
63
|
+
child.on('error', reject);
|
|
64
|
+
child.on('close', (code) => resolve(code ?? 0));
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---- App binary download ----------------------------------------------------
|
|
69
|
+
|
|
70
|
+
// Picks the GitHub Release asset that matches the host OS + arch. Squirrel.Windows
|
|
71
|
+
// emits `Codeling-<version> Setup.exe` (x64) and a `.nupkg`; MakerDMG emits
|
|
72
|
+
// `Codeling-<version>.dmg` (universal or per-arch depending on packagerConfig);
|
|
73
|
+
// MakerDeb / MakerRpm emit `codeling_<version>_<arch>.deb` and the .rpm
|
|
74
|
+
// equivalent. We match by extension first since the version + arch suffix
|
|
75
|
+
// vary across forge versions.
|
|
76
|
+
function pickAsset(assets) {
|
|
77
|
+
if (!Array.isArray(assets) || assets.length === 0) return null;
|
|
78
|
+
const byExt = (exts) =>
|
|
79
|
+
assets.find((a) =>
|
|
80
|
+
exts.some((ext) => typeof a?.name === 'string' && a.name.toLowerCase().endsWith(ext)),
|
|
81
|
+
);
|
|
82
|
+
if (isWindows()) return byExt(['.exe', '.msi']);
|
|
83
|
+
if (isMac()) return byExt(['.dmg']);
|
|
84
|
+
if (isLinux()) {
|
|
85
|
+
// Prefer .deb on systems with apt; otherwise .rpm. The test for `apt` is
|
|
86
|
+
// cheap enough to leave inline.
|
|
87
|
+
const hasApt = existsSync('/usr/bin/apt') || existsSync('/usr/bin/apt-get');
|
|
88
|
+
return hasApt ? byExt(['.deb']) ?? byExt(['.rpm']) : byExt(['.rpm']) ?? byExt(['.deb']);
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function fetchLatestRelease() {
|
|
94
|
+
const headers = { 'User-Agent': 'codeling-cli', Accept: 'application/vnd.github+json' };
|
|
95
|
+
// PAT helps avoid the 60/hour anonymous rate limit. Optional.
|
|
96
|
+
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
|
97
|
+
const res = await fetch(LATEST_RELEASE_URL, { headers });
|
|
98
|
+
if (res.status === 404) return null; // no release published yet
|
|
99
|
+
if (!res.ok) throw new Error(`GitHub API ${res.status}: ${await res.text()}`);
|
|
100
|
+
return res.json();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function downloadAsset(asset, destDir) {
|
|
104
|
+
if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
|
|
105
|
+
const dest = path.join(destDir, asset.name);
|
|
106
|
+
// Resume-skip: if a file with the right size is already there, reuse it.
|
|
107
|
+
if (existsSync(dest) && statSync(dest).size === asset.size) {
|
|
108
|
+
console.log(`Reusing already-downloaded ${asset.name}`);
|
|
109
|
+
return dest;
|
|
110
|
+
}
|
|
111
|
+
console.log(`Downloading ${asset.name} (${(asset.size / 1024 / 1024).toFixed(1)} MB)…`);
|
|
112
|
+
const headers = { 'User-Agent': 'codeling-cli', Accept: 'application/octet-stream' };
|
|
113
|
+
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
|
114
|
+
const res = await fetch(asset.browser_download_url, { headers, redirect: 'follow' });
|
|
115
|
+
if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
|
|
116
|
+
await new Promise((resolve, reject) => {
|
|
117
|
+
const out = createWriteStream(dest);
|
|
118
|
+
out.on('error', reject);
|
|
119
|
+
out.on('finish', resolve);
|
|
120
|
+
// Node's web stream → node stream bridge; available in Node 18+.
|
|
121
|
+
const reader = res.body.getReader();
|
|
122
|
+
const pump = () =>
|
|
123
|
+
reader.read().then(({ done, value }) => {
|
|
124
|
+
if (done) {
|
|
125
|
+
out.end();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
out.write(Buffer.from(value));
|
|
129
|
+
return pump();
|
|
130
|
+
});
|
|
131
|
+
pump().catch(reject);
|
|
132
|
+
});
|
|
133
|
+
return dest;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Runs the downloaded installer. Each platform has its own conventions:
|
|
137
|
+
// - Windows: Squirrel's .exe Setup runs interactively; the user sees a
|
|
138
|
+
// short progress UI, then the app launches.
|
|
139
|
+
// - macOS: mount the DMG and open it so Finder shows the drag-to-Applications
|
|
140
|
+
// window. Auto-copying with cp would silently bypass the gatekeeper prompt.
|
|
141
|
+
// - Linux: invoke the package manager, eliding sudo (let the user choose).
|
|
142
|
+
async function runInstaller(filePath) {
|
|
143
|
+
if (isWindows()) {
|
|
144
|
+
console.log(`Launching installer: ${filePath}`);
|
|
145
|
+
// Spawn detached so the installer keeps running after the CLI exits.
|
|
146
|
+
const child = spawn(filePath, [], { detached: true, stdio: 'ignore' });
|
|
147
|
+
child.unref();
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
if (isMac()) {
|
|
151
|
+
console.log(`Opening DMG: ${filePath}`);
|
|
152
|
+
console.log(' → Drag Codeling into Applications, then eject the DMG.');
|
|
153
|
+
return run('open', [filePath]);
|
|
154
|
+
}
|
|
155
|
+
if (isLinux()) {
|
|
156
|
+
if (filePath.endsWith('.deb')) {
|
|
157
|
+
console.log(`Run: sudo dpkg -i "${filePath}"`);
|
|
158
|
+
console.log(' (then `sudo apt-get install -f` if dependencies are missing)');
|
|
159
|
+
} else if (filePath.endsWith('.rpm')) {
|
|
160
|
+
console.log(`Run: sudo rpm -i "${filePath}"`);
|
|
161
|
+
}
|
|
162
|
+
return 0; // don't auto-elevate
|
|
163
|
+
}
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function downloadAndInstallApp() {
|
|
168
|
+
console.log(`Looking up latest release at github.com/${GITHUB_OWNER}/${GITHUB_REPO}…`);
|
|
169
|
+
let release;
|
|
170
|
+
try {
|
|
171
|
+
release = await fetchLatestRelease();
|
|
172
|
+
} catch (err) {
|
|
173
|
+
console.error(` GitHub lookup failed: ${err.message}`);
|
|
174
|
+
console.error(' Continuing — telemetry + Stop hook can still install. Re-run when');
|
|
175
|
+
console.error(' the network is reachable, or use --skip-app to suppress this step.');
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
if (!release) {
|
|
179
|
+
console.log(' No published release yet. Skipping app download.');
|
|
180
|
+
console.log(' Manual install: clone the repo, `npm install`, `npm start`.');
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
const asset = pickAsset(release.assets);
|
|
184
|
+
if (!asset) {
|
|
185
|
+
console.log(` Release ${release.tag_name} has no asset matching ${process.platform}.`);
|
|
186
|
+
console.log(' Skipping app download — telemetry + Stop hook will still install.');
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
const destDir = path.join(os.tmpdir(), 'codeling-install');
|
|
190
|
+
let filePath;
|
|
191
|
+
try {
|
|
192
|
+
filePath = await downloadAsset(asset, destDir);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
console.error(` Download failed: ${err.message}`);
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
const code = await runInstaller(filePath);
|
|
198
|
+
// Best-effort cleanup of the Windows installer; macOS DMG stays so the user
|
|
199
|
+
// can drag at their own pace, Linux .deb / .rpm stays so they can re-run
|
|
200
|
+
// the package-manager command if elevation fails.
|
|
201
|
+
if (isWindows()) {
|
|
202
|
+
unlink(filePath).catch(() => {});
|
|
203
|
+
}
|
|
204
|
+
return code;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---- Telemetry installer ----------------------------------------------------
|
|
208
|
+
|
|
209
|
+
async function runTelemetry(mode) {
|
|
210
|
+
if (isWindows()) {
|
|
211
|
+
const script = path.join(REPO_ROOT, 'scripts', 'install-telemetry.ps1');
|
|
212
|
+
// `-ExecutionPolicy Bypass` so the user doesn't need to relax script
|
|
213
|
+
// policy globally; the bypass is scoped to this invocation only.
|
|
214
|
+
const code = await run(
|
|
215
|
+
'powershell.exe',
|
|
216
|
+
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, mode],
|
|
217
|
+
{},
|
|
218
|
+
);
|
|
219
|
+
return code;
|
|
220
|
+
}
|
|
221
|
+
const script = path.join(REPO_ROOT, 'scripts', 'install-telemetry.sh');
|
|
222
|
+
return run('bash', [script, mode], {});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---- Stop hook installer ----------------------------------------------------
|
|
226
|
+
|
|
227
|
+
async function runStopHook(mode) {
|
|
228
|
+
const script = path.join(REPO_ROOT, 'scripts', 'install-stop-hook.mjs');
|
|
229
|
+
return run(process.execPath, [script, mode], {});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---- Flag parsing -----------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
function parseFlags(argv) {
|
|
235
|
+
const flags = new Set();
|
|
236
|
+
for (const arg of argv) {
|
|
237
|
+
if (arg.startsWith('--')) flags.add(arg);
|
|
238
|
+
}
|
|
239
|
+
return flags;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---- Subcommands ------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
async function install(flags) {
|
|
245
|
+
console.log('Codeling — installing');
|
|
246
|
+
console.log('');
|
|
247
|
+
const steps = [];
|
|
248
|
+
if (!flags.has('--skip-app')) steps.push(['App binary', () => downloadAndInstallApp()]);
|
|
249
|
+
if (!flags.has('--skip-otel')) steps.push(['Telemetry env vars', () => runTelemetry('install')]);
|
|
250
|
+
if (!flags.has('--skip-hook')) steps.push(['Stop hook', () => runStopHook('install')]);
|
|
251
|
+
let i = 0;
|
|
252
|
+
for (const [label, fn] of steps) {
|
|
253
|
+
i++;
|
|
254
|
+
console.log(`${i}/${steps.length} ${label}`);
|
|
255
|
+
console.log('─'.repeat(Math.max(20, label.length + 4)));
|
|
256
|
+
const code = await fn();
|
|
257
|
+
if (code !== 0) {
|
|
258
|
+
console.error(`${label} exited with code ${code}. Stopping.`);
|
|
259
|
+
process.exitCode = code;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
console.log('');
|
|
263
|
+
}
|
|
264
|
+
console.log('Done. Next:');
|
|
265
|
+
console.log(' • Restart your shell so the new env vars take effect.');
|
|
266
|
+
console.log(' • Launch Codeling from your applications menu (or wait for the');
|
|
267
|
+
console.log(' Windows installer to open it for you).');
|
|
268
|
+
console.log(' • Send a message through Claude Code and watch the tray come alive.');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function uninstall() {
|
|
272
|
+
console.log('Codeling — removing OS-level integrations');
|
|
273
|
+
console.log(' (The Codeling app itself is untouched — uninstall it via the OS.)');
|
|
274
|
+
console.log('');
|
|
275
|
+
console.log('1/2 Telemetry env vars');
|
|
276
|
+
console.log('─────────────────────');
|
|
277
|
+
const tCode = await runTelemetry('uninstall');
|
|
278
|
+
if (tCode !== 0) {
|
|
279
|
+
console.error(`Telemetry uninstall exited with code ${tCode}. Continuing.`);
|
|
280
|
+
}
|
|
281
|
+
console.log('');
|
|
282
|
+
console.log('2/2 Stop hook');
|
|
283
|
+
console.log('─────────────');
|
|
284
|
+
const sCode = await runStopHook('uninstall');
|
|
285
|
+
if (sCode !== 0) {
|
|
286
|
+
console.error(`Stop hook uninstall exited with code ${sCode}.`);
|
|
287
|
+
process.exitCode = sCode;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
console.log('');
|
|
291
|
+
console.log('Done.');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function status() {
|
|
295
|
+
console.log('Codeling — installation status');
|
|
296
|
+
console.log('');
|
|
297
|
+
console.log('Telemetry env vars');
|
|
298
|
+
console.log('─────────────────');
|
|
299
|
+
await runTelemetry('status');
|
|
300
|
+
console.log('');
|
|
301
|
+
console.log('Stop hook');
|
|
302
|
+
console.log('─────────');
|
|
303
|
+
await runStopHook('status');
|
|
304
|
+
console.log('');
|
|
305
|
+
console.log(`Platform: ${process.platform} (${os.release()})`);
|
|
306
|
+
console.log(`Repo: ${REPO_ROOT}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---- Dispatch ---------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
const args = process.argv.slice(2);
|
|
312
|
+
const mode = args.find((a) => !a.startsWith('--')) ?? 'install';
|
|
313
|
+
const flags = parseFlags(args);
|
|
314
|
+
const handlers = {
|
|
315
|
+
install: () => install(flags),
|
|
316
|
+
uninstall: () => uninstall(),
|
|
317
|
+
status: () => status(),
|
|
318
|
+
};
|
|
319
|
+
const handler = handlers[mode];
|
|
320
|
+
if (!handler) {
|
|
321
|
+
console.error(`Usage: codeling [install|uninstall|status] [--skip-app] [--skip-otel] [--skip-hook]`);
|
|
322
|
+
process.exit(2);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
handler().catch((err) => {
|
|
326
|
+
console.error(err.message ?? err);
|
|
327
|
+
process.exit(1);
|
|
328
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// Install / uninstall / inspect Codeling's Stop hook in ~/.claude/settings.json.
|
|
4
|
+
//
|
|
5
|
+
// Stop hooks fire on every Claude Code turn end; by POSTing each event to
|
|
6
|
+
// http://127.0.0.1:4318/codeling/stop-hook we add a supplementary message
|
|
7
|
+
// tally that catches turns the OTEL exporter dropped. See
|
|
8
|
+
// src/main/stop-hook.ts for the receiver-side algorithm.
|
|
9
|
+
//
|
|
10
|
+
// Modes:
|
|
11
|
+
// install Add Codeling's Stop hook entry (idempotent).
|
|
12
|
+
// uninstall Remove it.
|
|
13
|
+
// status Show whether it's present + the current entry.
|
|
14
|
+
//
|
|
15
|
+
// Endpoint defaults to http://127.0.0.1:4318/codeling/stop-hook. Override:
|
|
16
|
+
// ENDPOINT=http://127.0.0.1:4318/codeling/stop-hook node ./scripts/install-stop-hook.mjs install
|
|
17
|
+
//
|
|
18
|
+
// Usage:
|
|
19
|
+
// node ./scripts/install-stop-hook.mjs [install|uninstall|status]
|
|
20
|
+
//
|
|
21
|
+
// We tag our entry by including a marker substring in the command. Uninstall
|
|
22
|
+
// filters by that substring so we never touch hook entries the user added by
|
|
23
|
+
// hand.
|
|
24
|
+
|
|
25
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
26
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
|
|
30
|
+
const SETTINGS_DIR = path.join(os.homedir(), '.claude');
|
|
31
|
+
const SETTINGS_PATH = path.join(SETTINGS_DIR, 'settings.json');
|
|
32
|
+
const MARKER = '/codeling/stop-hook';
|
|
33
|
+
const ENDPOINT = process.env.ENDPOINT || 'http://127.0.0.1:4318/codeling/stop-hook';
|
|
34
|
+
|
|
35
|
+
// `--data-binary @-` reads the hook's JSON event from stdin verbatim. -sS
|
|
36
|
+
// quiets curl's progress bar but keeps errors visible. -m 2 caps wait at 2s
|
|
37
|
+
// so a Codeling-not-running situation doesn't slow Claude turn endings.
|
|
38
|
+
const command = `curl -sS -m 2 -X POST -H "Content-Type: application/json" --data-binary @- ${ENDPOINT}`;
|
|
39
|
+
|
|
40
|
+
const codelingHookEntry = {
|
|
41
|
+
matcher: '',
|
|
42
|
+
hooks: [
|
|
43
|
+
{
|
|
44
|
+
type: 'command',
|
|
45
|
+
command,
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function isCodelingHookEntry(entry) {
|
|
51
|
+
if (!entry || !Array.isArray(entry.hooks)) return false;
|
|
52
|
+
return entry.hooks.some(
|
|
53
|
+
(h) => typeof h?.command === 'string' && h.command.includes(MARKER),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readSettings() {
|
|
58
|
+
if (!existsSync(SETTINGS_PATH)) return {};
|
|
59
|
+
const raw = await readFile(SETTINGS_PATH, 'utf8');
|
|
60
|
+
if (raw.trim().length === 0) return {};
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(raw);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw new Error(`Failed to parse ${SETTINGS_PATH}: ${err.message}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function writeSettings(settings) {
|
|
69
|
+
if (!existsSync(SETTINGS_DIR)) mkdirSync(SETTINGS_DIR, { recursive: true });
|
|
70
|
+
// Pretty-print to keep the file legible if the user ever opens it. The
|
|
71
|
+
// Claude Code reader doesn't care about whitespace.
|
|
72
|
+
await writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getStopArray(settings) {
|
|
76
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') return [];
|
|
77
|
+
if (!Array.isArray(settings.hooks.Stop)) return [];
|
|
78
|
+
return settings.hooks.Stop;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function setStopArray(settings, arr) {
|
|
82
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
|
|
83
|
+
if (arr.length === 0) {
|
|
84
|
+
delete settings.hooks.Stop;
|
|
85
|
+
} else {
|
|
86
|
+
settings.hooks.Stop = arr;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function install() {
|
|
91
|
+
const settings = await readSettings();
|
|
92
|
+
const existing = getStopArray(settings);
|
|
93
|
+
// Drop any prior Codeling entries (handles re-install with new endpoint),
|
|
94
|
+
// then append the fresh one. Other Stop hooks the user has installed are
|
|
95
|
+
// preserved verbatim.
|
|
96
|
+
const filtered = existing.filter((e) => !isCodelingHookEntry(e));
|
|
97
|
+
filtered.push(codelingHookEntry);
|
|
98
|
+
setStopArray(settings, filtered);
|
|
99
|
+
await writeSettings(settings);
|
|
100
|
+
console.log(`Codeling Stop hook installed at ${SETTINGS_PATH}`);
|
|
101
|
+
console.log(`Endpoint: ${ENDPOINT}`);
|
|
102
|
+
console.log('Restart Claude Code (or new sessions will pick it up automatically).');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function uninstall() {
|
|
106
|
+
const settings = await readSettings();
|
|
107
|
+
const existing = getStopArray(settings);
|
|
108
|
+
const filtered = existing.filter((e) => !isCodelingHookEntry(e));
|
|
109
|
+
if (filtered.length === existing.length) {
|
|
110
|
+
console.log('No Codeling Stop hook entry found — nothing to remove.');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
setStopArray(settings, filtered);
|
|
114
|
+
await writeSettings(settings);
|
|
115
|
+
console.log(`Codeling Stop hook removed from ${SETTINGS_PATH}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function status() {
|
|
119
|
+
if (!existsSync(SETTINGS_PATH)) {
|
|
120
|
+
console.log(`No ${SETTINGS_PATH} present yet — Stop hook is not installed.`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const settings = await readSettings();
|
|
124
|
+
const existing = getStopArray(settings);
|
|
125
|
+
const ours = existing.filter(isCodelingHookEntry);
|
|
126
|
+
if (ours.length === 0) {
|
|
127
|
+
console.log('Codeling Stop hook is not installed.');
|
|
128
|
+
if (existing.length > 0) {
|
|
129
|
+
console.log(`(${existing.length} other Stop hook entr${existing.length === 1 ? 'y' : 'ies'} present.)`);
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
console.log('Codeling Stop hook present:');
|
|
134
|
+
console.log(JSON.stringify(ours, null, 2));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const mode = process.argv[2] ?? 'install';
|
|
138
|
+
const handlers = { install, uninstall, status };
|
|
139
|
+
const handler = handlers[mode];
|
|
140
|
+
if (!handler) {
|
|
141
|
+
console.error(`Usage: node ${path.relative(process.cwd(), process.argv[1])} [install|uninstall|status]`);
|
|
142
|
+
process.exit(2);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
handler().catch((err) => {
|
|
146
|
+
console.error(err.message);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Set / unset / inspect the User-scope OTEL env vars Claude Code needs to
|
|
4
|
+
feed Codeling's local receiver.
|
|
5
|
+
|
|
6
|
+
.DESCRIPTION
|
|
7
|
+
Interim shim until `npx codeling install` lands. Persists six User-scope
|
|
8
|
+
environment variables so every newly-launched Claude Code session emits
|
|
9
|
+
metrics + logs to http://127.0.0.1:4318 (Codeling's HTTP receiver).
|
|
10
|
+
|
|
11
|
+
Modes:
|
|
12
|
+
install Set the vars (default).
|
|
13
|
+
uninstall Remove them.
|
|
14
|
+
status Show their current User-scope values.
|
|
15
|
+
|
|
16
|
+
Endpoint defaults to http://127.0.0.1:4318. Override with -Endpoint.
|
|
17
|
+
Existing OTEL_EXPORTER_OTLP_ENDPOINT pointing at a different host triggers
|
|
18
|
+
a warning; pass -Force to overwrite anyway.
|
|
19
|
+
|
|
20
|
+
.EXAMPLE
|
|
21
|
+
.\scripts\install-telemetry.ps1 install
|
|
22
|
+
.\scripts\install-telemetry.ps1 install -Endpoint http://127.0.0.1:4318
|
|
23
|
+
.\scripts\install-telemetry.ps1 uninstall
|
|
24
|
+
.\scripts\install-telemetry.ps1 status
|
|
25
|
+
|
|
26
|
+
.NOTES
|
|
27
|
+
Requires PowerShell 5.1+ (Windows). Restart any open shells / VS Code after
|
|
28
|
+
install for them to pick up the new env vars.
|
|
29
|
+
#>
|
|
30
|
+
|
|
31
|
+
[CmdletBinding()]
|
|
32
|
+
param(
|
|
33
|
+
[Parameter(Position = 0)]
|
|
34
|
+
[ValidateSet('install', 'uninstall', 'status')]
|
|
35
|
+
[string]$Mode = 'install',
|
|
36
|
+
|
|
37
|
+
[string]$Endpoint = 'http://127.0.0.1:4318',
|
|
38
|
+
|
|
39
|
+
[switch]$Force
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
$ErrorActionPreference = 'Stop'
|
|
43
|
+
|
|
44
|
+
# Names listed in the order users typically reason about (toggle, endpoint,
|
|
45
|
+
# protocol, exporters, interval) so the status table reads naturally.
|
|
46
|
+
$VarNames = @(
|
|
47
|
+
'CLAUDE_CODE_ENABLE_TELEMETRY',
|
|
48
|
+
'OTEL_EXPORTER_OTLP_ENDPOINT',
|
|
49
|
+
'OTEL_EXPORTER_OTLP_PROTOCOL',
|
|
50
|
+
'OTEL_METRICS_EXPORTER',
|
|
51
|
+
'OTEL_LOGS_EXPORTER',
|
|
52
|
+
'OTEL_METRIC_EXPORT_INTERVAL'
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
$DesiredValues = @{
|
|
56
|
+
'CLAUDE_CODE_ENABLE_TELEMETRY' = '1'
|
|
57
|
+
'OTEL_EXPORTER_OTLP_ENDPOINT' = $Endpoint
|
|
58
|
+
'OTEL_EXPORTER_OTLP_PROTOCOL' = 'http/protobuf'
|
|
59
|
+
'OTEL_METRICS_EXPORTER' = 'otlp'
|
|
60
|
+
'OTEL_LOGS_EXPORTER' = 'otlp'
|
|
61
|
+
'OTEL_METRIC_EXPORT_INTERVAL' = '10000'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function Get-UserVar {
|
|
65
|
+
param([string]$Name)
|
|
66
|
+
return [Environment]::GetEnvironmentVariable($Name, 'User')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function Show-Status {
|
|
70
|
+
$rows = foreach ($k in $VarNames) {
|
|
71
|
+
$v = Get-UserVar $k
|
|
72
|
+
$display = if ([string]::IsNullOrEmpty($v)) { '(unset)' } else { $v }
|
|
73
|
+
[pscustomobject]@{ Variable = $k; Value = $display }
|
|
74
|
+
}
|
|
75
|
+
$rows | Format-Table -AutoSize
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
switch ($Mode) {
|
|
79
|
+
'install' {
|
|
80
|
+
$existing = Get-UserVar 'OTEL_EXPORTER_OTLP_ENDPOINT'
|
|
81
|
+
if (-not [string]::IsNullOrEmpty($existing) -and $existing -ne $Endpoint -and -not $Force) {
|
|
82
|
+
Write-Warning "OTEL_EXPORTER_OTLP_ENDPOINT is already set to '$existing'."
|
|
83
|
+
Write-Warning "Codeling wants to point it at '$Endpoint'."
|
|
84
|
+
Write-Warning 'Pass -Force to overwrite, or set -Endpoint to match your existing value.'
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
foreach ($k in $VarNames) {
|
|
89
|
+
[Environment]::SetEnvironmentVariable($k, $DesiredValues[$k], 'User')
|
|
90
|
+
}
|
|
91
|
+
Write-Host 'Codeling telemetry env vars installed (User scope).' -ForegroundColor Green
|
|
92
|
+
Write-Host 'Restart any open shells, VS Code, or terminals for them to take effect.'
|
|
93
|
+
Write-Host ''
|
|
94
|
+
Show-Status
|
|
95
|
+
}
|
|
96
|
+
'uninstall' {
|
|
97
|
+
foreach ($k in $VarNames) {
|
|
98
|
+
[Environment]::SetEnvironmentVariable($k, $null, 'User')
|
|
99
|
+
}
|
|
100
|
+
Write-Host 'Codeling telemetry env vars removed (User scope).' -ForegroundColor Yellow
|
|
101
|
+
Write-Host 'Restart any open shells / VS Code for the unset to take effect.'
|
|
102
|
+
}
|
|
103
|
+
'status' {
|
|
104
|
+
Show-Status
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Set / unset / inspect the OTEL env vars Claude Code needs to feed Codeling's
|
|
4
|
+
# local receiver. Interim shim until `npx codeling install` lands.
|
|
5
|
+
#
|
|
6
|
+
# Modes:
|
|
7
|
+
# install Add a marked block to the shell rc (default).
|
|
8
|
+
# uninstall Remove the marked block.
|
|
9
|
+
# status Show whether the block is present + current process env.
|
|
10
|
+
#
|
|
11
|
+
# Endpoint defaults to http://127.0.0.1:4318. Override with `ENDPOINT=...`:
|
|
12
|
+
# ENDPOINT=http://192.168.1.5:4318 ./scripts/install-telemetry.sh install
|
|
13
|
+
#
|
|
14
|
+
# Detects zsh vs bash and writes to ~/.zshrc or ~/.bashrc respectively. Block
|
|
15
|
+
# is delimited with markers (`# >>> codeling-telemetry >>>` / `# <<< ... <<<`)
|
|
16
|
+
# so uninstall is a clean targeted removal — no clobbering existing edits.
|
|
17
|
+
|
|
18
|
+
set -euo pipefail
|
|
19
|
+
|
|
20
|
+
MODE="${1:-install}"
|
|
21
|
+
ENDPOINT="${ENDPOINT:-http://127.0.0.1:4318}"
|
|
22
|
+
|
|
23
|
+
# Pick the rc file matching the user's login shell. Prefer $SHELL since we want
|
|
24
|
+
# the file Claude Code's launcher will source, not the shell running this script.
|
|
25
|
+
case "$(basename "${SHELL:-}")" in
|
|
26
|
+
zsh) RC="${ZDOTDIR:-$HOME}/.zshrc" ;;
|
|
27
|
+
*) RC="$HOME/.bashrc" ;;
|
|
28
|
+
esac
|
|
29
|
+
|
|
30
|
+
MARK_BEGIN='# >>> codeling-telemetry >>>'
|
|
31
|
+
MARK_END='# <<< codeling-telemetry <<<'
|
|
32
|
+
|
|
33
|
+
# Build the block fresh on every install so an updated ENDPOINT propagates.
|
|
34
|
+
emit_block() {
|
|
35
|
+
cat <<EOF
|
|
36
|
+
$MARK_BEGIN
|
|
37
|
+
# Managed by Codeling — scripts/install-telemetry.sh. Edit via the script
|
|
38
|
+
# (install/uninstall/status modes) rather than by hand.
|
|
39
|
+
export CLAUDE_CODE_ENABLE_TELEMETRY=1
|
|
40
|
+
export OTEL_EXPORTER_OTLP_ENDPOINT="$ENDPOINT"
|
|
41
|
+
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
|
42
|
+
export OTEL_METRICS_EXPORTER=otlp
|
|
43
|
+
export OTEL_LOGS_EXPORTER=otlp
|
|
44
|
+
export OTEL_METRIC_EXPORT_INTERVAL=10000
|
|
45
|
+
$MARK_END
|
|
46
|
+
EOF
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# `sed -i.bak` works on both BSD (macOS) and GNU (Linux) — explicit backup
|
|
50
|
+
# extension dodges the macOS gotcha where `-i` alone takes the next arg as the
|
|
51
|
+
# extension. We delete the backup immediately after.
|
|
52
|
+
remove_block() {
|
|
53
|
+
if [ -f "$RC" ]; then
|
|
54
|
+
sed -i.codeling.bak "/^# >>> codeling-telemetry >>>$/,/^# <<< codeling-telemetry <<<$/d" "$RC"
|
|
55
|
+
rm -f "$RC.codeling.bak"
|
|
56
|
+
fi
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
block_present() {
|
|
60
|
+
[ -f "$RC" ] && grep -q "^# >>> codeling-telemetry >>>$" "$RC"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
case "$MODE" in
|
|
64
|
+
install)
|
|
65
|
+
if block_present; then
|
|
66
|
+
remove_block
|
|
67
|
+
fi
|
|
68
|
+
emit_block >> "$RC"
|
|
69
|
+
echo "Codeling telemetry block written to $RC"
|
|
70
|
+
echo "Restart your shell (or 'source $RC') for the env vars to take effect."
|
|
71
|
+
echo "VS Code / other apps need to be relaunched too."
|
|
72
|
+
;;
|
|
73
|
+
uninstall)
|
|
74
|
+
if block_present; then
|
|
75
|
+
remove_block
|
|
76
|
+
echo "Codeling telemetry block removed from $RC"
|
|
77
|
+
else
|
|
78
|
+
echo "No Codeling telemetry block found in $RC — nothing to remove."
|
|
79
|
+
fi
|
|
80
|
+
;;
|
|
81
|
+
status)
|
|
82
|
+
if block_present; then
|
|
83
|
+
echo "Block present in $RC:"
|
|
84
|
+
sed -n "/^# >>> codeling-telemetry >>>$/,/^# <<< codeling-telemetry <<<$/p" "$RC"
|
|
85
|
+
else
|
|
86
|
+
echo "No Codeling telemetry block in $RC"
|
|
87
|
+
fi
|
|
88
|
+
echo
|
|
89
|
+
echo "Current process env (only reflects what's exported in *this* shell):"
|
|
90
|
+
for v in CLAUDE_CODE_ENABLE_TELEMETRY \
|
|
91
|
+
OTEL_EXPORTER_OTLP_ENDPOINT \
|
|
92
|
+
OTEL_EXPORTER_OTLP_PROTOCOL \
|
|
93
|
+
OTEL_METRICS_EXPORTER \
|
|
94
|
+
OTEL_LOGS_EXPORTER \
|
|
95
|
+
OTEL_METRIC_EXPORT_INTERVAL; do
|
|
96
|
+
printf ' %s=%s\n' "$v" "${!v:-(unset)}"
|
|
97
|
+
done
|
|
98
|
+
;;
|
|
99
|
+
*)
|
|
100
|
+
echo "Usage: $0 [install|uninstall|status]" >&2
|
|
101
|
+
echo " ENDPOINT=http://host:port $0 install # custom endpoint" >&2
|
|
102
|
+
exit 1
|
|
103
|
+
;;
|
|
104
|
+
esac
|