skillo 0.1.5 → 0.2.2

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.
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ getConfigValue,
4
+ getDefaultConfig,
5
+ loadConfig,
6
+ saveConfig,
7
+ setConfigValue
8
+ } from "./chunk-CPL3P2OF.js";
9
+ import "./chunk-WJKZWKER.js";
10
+ export {
11
+ getConfigValue,
12
+ getDefaultConfig,
13
+ loadConfig,
14
+ saveConfig,
15
+ setConfigValue
16
+ };
17
+ //# sourceMappingURL=config-P5EM5L7N.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -1,5 +1,5 @@
1
1
  // src/daemon-runner.ts
2
- import { existsSync as existsSync4, writeFileSync as writeFileSync2, unlinkSync, readFileSync as readFileSync2, readdirSync, appendFileSync } from "fs";
2
+ import { existsSync as existsSync6, writeFileSync as writeFileSync4, unlinkSync, readFileSync as readFileSync4, readdirSync as readdirSync2, appendFileSync } from "fs";
3
3
 
4
4
  // src/core/config.ts
5
5
  import { readFileSync, writeFileSync, existsSync as existsSync2 } from "fs";
@@ -25,11 +25,19 @@ function getConfigDir() {
25
25
  if (xdgConfig) return join(xdgConfig, "skillo");
26
26
  return join(getHomeDir(), ".config", "skillo");
27
27
  }
28
- function ensureDirectory(path2) {
29
- if (existsSync(path2)) {
28
+ function getSkillsDir() {
29
+ const envPath = process.env.SKILLO_SKILLS_DIR;
30
+ if (envPath) return envPath;
31
+ return join(getClaudeDir(), "skills");
32
+ }
33
+ function getClaudeDir() {
34
+ return join(getHomeDir(), ".claude");
35
+ }
36
+ function ensureDirectory(path3) {
37
+ if (existsSync(path3)) {
30
38
  return false;
31
39
  }
32
- mkdirSync(path2, { recursive: true });
40
+ mkdirSync(path3, { recursive: true });
33
41
  return true;
34
42
  }
35
43
  function getLogFile() {
@@ -130,8 +138,8 @@ function deepMerge(base, override) {
130
138
  }
131
139
  return result;
132
140
  }
133
- function loadConfig(path2) {
134
- const configPath = path2 || getConfigFile();
141
+ function loadConfig(path3) {
142
+ const configPath = path3 || getConfigFile();
135
143
  if (!existsSync2(configPath)) {
136
144
  return getDefaultConfig();
137
145
  }
@@ -145,8 +153,8 @@ function loadConfig(path2) {
145
153
  return getDefaultConfig();
146
154
  }
147
155
  }
148
- function saveConfig(config, path2) {
149
- const configPath = path2 || getConfigFile();
156
+ function saveConfig(config, path3) {
157
+ const configPath = path3 || getConfigFile();
150
158
  ensureDirectory(dirname(configPath));
151
159
  const converted = convertKeysToSnakeCase(config);
152
160
  const content = YAML.stringify(converted, {
@@ -500,16 +508,16 @@ var ApiClient = class {
500
508
  /**
501
509
  * Disconnect a project from tracking
502
510
  */
503
- async disconnectProject(path2) {
504
- return this.request(`/projects/connect?path=${encodeURIComponent(path2)}`, {
511
+ async disconnectProject(path3) {
512
+ return this.request(`/projects/connect?path=${encodeURIComponent(path3)}`, {
505
513
  method: "DELETE"
506
514
  });
507
515
  }
508
516
  /**
509
517
  * Get tracking status for a project
510
518
  */
511
- async getProjectStatus(path2) {
512
- return this.request(`/projects/connect?path=${encodeURIComponent(path2)}`, {
519
+ async getProjectStatus(path3) {
520
+ return this.request(`/projects/connect?path=${encodeURIComponent(path3)}`, {
513
521
  method: "GET"
514
522
  });
515
523
  }
@@ -525,8 +533,8 @@ var ApiClient = class {
525
533
  * Check if a path is in a tracked project
526
534
  * Returns the project if tracked, null if not
527
535
  */
528
- async isProjectTracked(path2) {
529
- const result = await this.getProjectStatus(path2);
536
+ async isProjectTracked(path3) {
537
+ const result = await this.getProjectStatus(path3);
530
538
  if (result.success && result.data?.connected) {
531
539
  return {
532
540
  tracked: true,
@@ -717,6 +725,287 @@ var ClaudeWatcher = class {
717
725
  }
718
726
  };
719
727
 
728
+ // src/core/skill-usage-detector.ts
729
+ import * as fs2 from "fs";
730
+ import * as path2 from "path";
731
+ import * as readline2 from "readline";
732
+ var MAX_NEW_BYTES_PER_FILE = 2 * 1024 * 1024;
733
+ var OFFSETS_FILE = "skill-usage-offsets.json";
734
+ var SkillUsageDetector = class {
735
+ intervalId = null;
736
+ intervalMs;
737
+ callbacks;
738
+ client;
739
+ constructor(client, options = {}) {
740
+ this.client = client;
741
+ this.intervalMs = options.intervalMs || 3e4;
742
+ this.callbacks = options.callbacks || {};
743
+ }
744
+ log(level, msg) {
745
+ this.callbacks.log?.(level, msg);
746
+ }
747
+ async start() {
748
+ this.initOffsets();
749
+ this.log("INFO", "Skill usage detector started");
750
+ this.intervalId = setInterval(() => this.detect(), this.intervalMs);
751
+ }
752
+ stop() {
753
+ if (this.intervalId) {
754
+ clearInterval(this.intervalId);
755
+ this.intervalId = null;
756
+ }
757
+ }
758
+ /** Build slug inventory from ~/.claude/skills/ */
759
+ getDeployedSkillSlugs() {
760
+ const slugs = /* @__PURE__ */ new Set();
761
+ const skillsDir = getSkillsDir();
762
+ if (!fs2.existsSync(skillsDir)) return slugs;
763
+ try {
764
+ const entries = fs2.readdirSync(skillsDir, { withFileTypes: true });
765
+ for (const entry of entries) {
766
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
767
+ const skillFile = path2.join(skillsDir, entry.name, "SKILL.md");
768
+ if (fs2.existsSync(skillFile)) {
769
+ slugs.add(entry.name);
770
+ }
771
+ }
772
+ } catch {
773
+ }
774
+ return slugs;
775
+ }
776
+ /** Encode project path to Claude's directory name format */
777
+ encodeProjectPath(projectPath) {
778
+ return projectPath.replace(/\//g, "-");
779
+ }
780
+ /** Find session JSONL files for tracked projects */
781
+ getSessionFiles() {
782
+ const claudeDir = getClaudeDir();
783
+ const projectsDir = path2.join(claudeDir, "projects");
784
+ if (!fs2.existsSync(projectsDir)) return [];
785
+ const files = [];
786
+ try {
787
+ const projectDirs = fs2.readdirSync(projectsDir, { withFileTypes: true });
788
+ for (const dir of projectDirs) {
789
+ if (!dir.isDirectory()) continue;
790
+ const dirPath = path2.join(projectsDir, dir.name);
791
+ const jsonlFiles = fs2.readdirSync(dirPath).filter(
792
+ (f) => f.endsWith(".jsonl") && !f.startsWith("agent-")
793
+ );
794
+ for (const f of jsonlFiles) {
795
+ files.push(path2.join(dirPath, f));
796
+ }
797
+ }
798
+ } catch {
799
+ }
800
+ return files;
801
+ }
802
+ /** Load persisted offsets */
803
+ loadOffsets() {
804
+ const offsetsPath = path2.join(getDataDir(), OFFSETS_FILE);
805
+ try {
806
+ if (fs2.existsSync(offsetsPath)) {
807
+ return JSON.parse(fs2.readFileSync(offsetsPath, "utf-8"));
808
+ }
809
+ } catch {
810
+ }
811
+ return {};
812
+ }
813
+ /** Save offsets */
814
+ saveOffsets(state) {
815
+ ensureDirectory(getDataDir());
816
+ const offsetsPath = path2.join(getDataDir(), OFFSETS_FILE);
817
+ try {
818
+ fs2.writeFileSync(offsetsPath, JSON.stringify(state), "utf-8");
819
+ } catch {
820
+ }
821
+ }
822
+ /** Initialize offsets to current file sizes (skip existing data) */
823
+ initOffsets() {
824
+ const files = this.getSessionFiles();
825
+ const state = this.loadOffsets();
826
+ for (const file of files) {
827
+ if (!state[file]) {
828
+ try {
829
+ const stats = fs2.statSync(file);
830
+ state[file] = { byteOffset: stats.size, lastModified: stats.mtimeMs };
831
+ } catch {
832
+ }
833
+ }
834
+ }
835
+ this.saveOffsets(state);
836
+ }
837
+ /** Main detection cycle */
838
+ async detect() {
839
+ try {
840
+ const skillSlugs = this.getDeployedSkillSlugs();
841
+ if (skillSlugs.size === 0) return;
842
+ const skillsDir = getSkillsDir();
843
+ const files = this.getSessionFiles();
844
+ const offsets = this.loadOffsets();
845
+ const events = [];
846
+ for (const file of files) {
847
+ try {
848
+ const stats = fs2.statSync(file);
849
+ const currentOffset = offsets[file]?.byteOffset ?? 0;
850
+ const currentMtime = offsets[file]?.lastModified ?? 0;
851
+ if (stats.size <= currentOffset && stats.mtimeMs === currentMtime) continue;
852
+ const startOffset = Math.max(currentOffset, stats.size - MAX_NEW_BYTES_PER_FILE);
853
+ if (stats.size <= startOffset) {
854
+ offsets[file] = { byteOffset: stats.size, lastModified: stats.mtimeMs };
855
+ continue;
856
+ }
857
+ const stream = fs2.createReadStream(file, {
858
+ start: startOffset,
859
+ encoding: "utf-8"
860
+ });
861
+ const rl = readline2.createInterface({ input: stream, crlfDelay: Infinity });
862
+ for await (const line of rl) {
863
+ if (!line.trim()) continue;
864
+ try {
865
+ const entry = JSON.parse(line);
866
+ const msg = entry?.message;
867
+ if (!msg || msg.role !== "assistant") continue;
868
+ const content = msg.content;
869
+ if (!Array.isArray(content)) continue;
870
+ const sessionId = entry.sessionId || "";
871
+ const timestamp = entry.timestamp || (/* @__PURE__ */ new Date()).toISOString();
872
+ const cwd = entry.cwd || "";
873
+ for (const block of content) {
874
+ if (block?.type !== "tool_use") continue;
875
+ const toolName = block.name;
876
+ const toolInput = block.input || {};
877
+ if (toolName === "Read") {
878
+ const filePath = toolInput.file_path || "";
879
+ if (filePath.includes("/skills/") && filePath.endsWith("/SKILL.md")) {
880
+ const parts = filePath.split("/skills/");
881
+ if (parts.length >= 2) {
882
+ const afterSkills = parts[parts.length - 1];
883
+ const slug = afterSkills.split("/")[0];
884
+ if (slug && skillSlugs.has(slug)) {
885
+ events.push({
886
+ skillSlug: slug,
887
+ claudeSessionId: sessionId,
888
+ projectPath: cwd,
889
+ timestamp
890
+ });
891
+ }
892
+ }
893
+ }
894
+ }
895
+ }
896
+ } catch {
897
+ }
898
+ }
899
+ offsets[file] = { byteOffset: stats.size, lastModified: stats.mtimeMs };
900
+ } catch {
901
+ }
902
+ }
903
+ for (const key of Object.keys(offsets)) {
904
+ if (!fs2.existsSync(key)) {
905
+ delete offsets[key];
906
+ }
907
+ }
908
+ this.saveOffsets(offsets);
909
+ const seen = /* @__PURE__ */ new Set();
910
+ const uniqueEvents = events.filter((e) => {
911
+ const key = `${e.skillSlug}:${e.claudeSessionId}:${e.timestamp}`;
912
+ if (seen.has(key)) return false;
913
+ seen.add(key);
914
+ return true;
915
+ });
916
+ if (uniqueEvents.length > 0) {
917
+ this.log("INFO", `Detected ${uniqueEvents.length} skill usage(s), reporting...`);
918
+ const response = await this.client.reportSkillUsage(uniqueEvents);
919
+ if (response.success) {
920
+ this.log("INFO", `Reported ${response.data?.logged ?? 0} skill usage(s)`);
921
+ this.callbacks.onDetection?.(response.data?.logged ?? 0);
922
+ } else {
923
+ this.log("ERROR", `Report failed: ${response.error}`);
924
+ }
925
+ }
926
+ } catch (error) {
927
+ const err = error instanceof Error ? error : new Error(String(error));
928
+ this.log("ERROR", `Detection error: ${err.message}`);
929
+ this.callbacks.onError?.(err);
930
+ }
931
+ }
932
+ };
933
+
934
+ // src/utils/status-writer.ts
935
+ import { writeFileSync as writeFileSync3, readFileSync as readFileSync3, existsSync as existsSync5 } from "fs";
936
+ import { join as join4 } from "path";
937
+ var STATUS_FILE = "daemon-status.json";
938
+ var WRITE_INTERVAL_MS = 1e4;
939
+ var StatusWriter = class _StatusWriter {
940
+ intervalId = null;
941
+ status;
942
+ filePath;
943
+ constructor() {
944
+ this.filePath = join4(getDataDir(), STATUS_FILE);
945
+ this.status = {
946
+ running: true,
947
+ pid: process.pid,
948
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
949
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
950
+ claudeWatcher: { lastSync: null, promptsSynced: 0, lastError: null },
951
+ skillDetector: { deployedSkills: 0, usagesDetected: 0, lastDetection: null, lastError: null },
952
+ trackedProjects: [],
953
+ activeSessions: 0
954
+ };
955
+ }
956
+ /** Start periodic writes */
957
+ start() {
958
+ ensureDirectory(getDataDir());
959
+ this.write();
960
+ this.intervalId = setInterval(() => this.write(), WRITE_INTERVAL_MS);
961
+ }
962
+ /** Stop writing and mark as not running */
963
+ stop() {
964
+ if (this.intervalId) {
965
+ clearInterval(this.intervalId);
966
+ this.intervalId = null;
967
+ }
968
+ this.status.running = false;
969
+ this.status.pid = null;
970
+ this.status.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
971
+ this.write();
972
+ }
973
+ /** Merge partial status updates */
974
+ update(partial) {
975
+ if (partial.claudeWatcher) {
976
+ this.status.claudeWatcher = { ...this.status.claudeWatcher, ...partial.claudeWatcher };
977
+ delete partial.claudeWatcher;
978
+ }
979
+ if (partial.skillDetector) {
980
+ this.status.skillDetector = { ...this.status.skillDetector, ...partial.skillDetector };
981
+ delete partial.skillDetector;
982
+ }
983
+ Object.assign(this.status, partial);
984
+ }
985
+ /** Get the status file path */
986
+ static getStatusFilePath() {
987
+ return join4(getDataDir(), STATUS_FILE);
988
+ }
989
+ /** Read current status from disk (static, for tray/status commands) */
990
+ static read() {
991
+ const filePath = _StatusWriter.getStatusFilePath();
992
+ try {
993
+ if (existsSync5(filePath)) {
994
+ return JSON.parse(readFileSync3(filePath, "utf-8"));
995
+ }
996
+ } catch {
997
+ }
998
+ return null;
999
+ }
1000
+ write() {
1001
+ this.status.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1002
+ try {
1003
+ writeFileSync3(this.filePath, JSON.stringify(this.status, null, 2), "utf-8");
1004
+ } catch {
1005
+ }
1006
+ }
1007
+ };
1008
+
720
1009
  // src/daemon-runner.ts
721
1010
  function log(level, msg) {
722
1011
  const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] [${level}] ${msg}
@@ -734,7 +1023,7 @@ async function updateTrackedProjectsCache(client) {
734
1023
  updatedAt: Date.now(),
735
1024
  projects: result.data.projects.filter((p) => p.trackingEnabled).map((p) => ({ path: p.path, name: p.name }))
736
1025
  };
737
- writeFileSync2(getTrackedProjectsCacheFile(), JSON.stringify(cacheData, null, 2));
1026
+ writeFileSync4(getTrackedProjectsCacheFile(), JSON.stringify(cacheData, null, 2));
738
1027
  log("DEBUG", `Updated tracked projects cache: ${cacheData.projects.length} project(s)`);
739
1028
  }
740
1029
  } catch (error) {
@@ -743,9 +1032,9 @@ async function updateTrackedProjectsCache(client) {
743
1032
  }
744
1033
  async function cleanupStaleSessions(client) {
745
1034
  const sessionsDir = getActiveSessionsDir();
746
- if (!existsSync4(sessionsDir)) return;
1035
+ if (!existsSync6(sessionsDir)) return;
747
1036
  try {
748
- const files = readdirSync(sessionsDir);
1037
+ const files = readdirSync2(sessionsDir);
749
1038
  for (const file of files) {
750
1039
  if (!file.endsWith(".json")) continue;
751
1040
  const pid = parseInt(file.replace(".json", ""), 10);
@@ -758,7 +1047,7 @@ async function cleanupStaleSessions(client) {
758
1047
  }
759
1048
  if (!isRunning) {
760
1049
  try {
761
- const sessionData = JSON.parse(readFileSync2(`${sessionsDir}/${file}`, "utf-8"));
1050
+ const sessionData = JSON.parse(readFileSync4(`${sessionsDir}/${file}`, "utf-8"));
762
1051
  if (sessionData.sessionId) {
763
1052
  await client.endSession(sessionData.sessionId);
764
1053
  log("INFO", `Ended stale session ${sessionData.sessionId.slice(0, 8)} (PID ${pid} dead)`);
@@ -778,11 +1067,13 @@ async function cleanupStaleSessions(client) {
778
1067
  }
779
1068
  async function main() {
780
1069
  const pidFile = getPidFile();
781
- writeFileSync2(pidFile, String(process.pid));
1070
+ writeFileSync4(pidFile, String(process.pid));
782
1071
  log("INFO", `Daemon starting (PID: ${process.pid})`);
1072
+ const statusWriter = new StatusWriter();
783
1073
  const cleanup = () => {
784
1074
  log("INFO", "Daemon shutting down");
785
- if (existsSync4(pidFile)) {
1075
+ statusWriter.stop();
1076
+ if (existsSync6(pidFile)) {
786
1077
  try {
787
1078
  unlinkSync(pidFile);
788
1079
  } catch {
@@ -818,9 +1109,34 @@ async function main() {
818
1109
  }
819
1110
  });
820
1111
  await watcher.start();
821
- setInterval(() => updateTrackedProjectsCache(client), 6e4);
1112
+ const skillDetector = new SkillUsageDetector(client, {
1113
+ intervalMs: 3e4,
1114
+ callbacks: {
1115
+ onDetection: (count) => {
1116
+ log("INFO", `Detected ${count} skill usage(s)`);
1117
+ statusWriter.update({ skillDetector: { usagesDetected: count, lastDetection: (/* @__PURE__ */ new Date()).toISOString(), lastError: null } });
1118
+ },
1119
+ onError: (err) => {
1120
+ log("ERROR", `Skill detection error: ${err.message}`);
1121
+ statusWriter.update({ skillDetector: { lastError: err.message } });
1122
+ },
1123
+ log: (level, msg) => log(level, msg)
1124
+ }
1125
+ });
1126
+ await skillDetector.start();
1127
+ statusWriter.start();
1128
+ setInterval(async () => {
1129
+ await updateTrackedProjectsCache(client);
1130
+ try {
1131
+ const cacheData = JSON.parse(readFileSync4(getTrackedProjectsCacheFile(), "utf-8"));
1132
+ statusWriter.update({
1133
+ trackedProjects: cacheData.projects || []
1134
+ });
1135
+ } catch {
1136
+ }
1137
+ }, 6e4);
822
1138
  setInterval(() => cleanupStaleSessions(client), 3e5);
823
- log("INFO", "Daemon started successfully \u2014 watching Claude conversations");
1139
+ log("INFO", "Daemon started successfully \u2014 watching Claude conversations & skill usage");
824
1140
  await new Promise(() => {
825
1141
  });
826
1142
  }