getgloss 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,11 +6,18 @@
6
6
 
7
7
  Gloss is a local browser review loop for coding agents. It captures your current
8
8
  git diff, opens a localhost review UI, lets you attach comments to changed
9
- lines or ranges, and writes structured feedback back into the repo for an
10
- agent to re-ingest.
9
+ lines or ranges, and writes structured feedback under `~/.gloss` for an agent
10
+ to re-ingest.
11
11
 
12
12
  ## Install
13
13
 
14
+ ```bash
15
+ brew install iamrajjoshi/tap/gloss
16
+ gloss open --json
17
+ ```
18
+
19
+ With npm:
20
+
14
21
  ```bash
15
22
  npm install -g getgloss
16
23
  gloss open --json
@@ -25,13 +32,13 @@ npx getgloss open --json
25
32
  For a new agent chat, use:
26
33
 
27
34
  ```text
28
- Install Gloss with npm. Then read https://getgloss.dev/setup.md.
35
+ Install Gloss with Homebrew or npm. Then read https://getgloss.dev/setup.md.
29
36
  ```
30
37
 
31
38
  ### Claude Code Skill
32
39
 
33
- Gloss ships a Claude Code skill at `skill/SKILL.md`. Install it with the
34
- [`skills` CLI](https://github.com/vercel-labs/agent-skills):
40
+ Gloss ships a packaged Claude Code skill at `skill/SKILL.md`. Install it with
41
+ the [`skills` CLI](https://github.com/vercel-labs/agent-skills):
35
42
 
36
43
  ```bash
37
44
  # Global (available across all projects)
@@ -42,9 +49,12 @@ npx skills add iamrajjoshi/gloss --skill gloss -a claude-code
42
49
  ```
43
50
 
44
51
  `-g` installs to `~/.claude/skills/`, `-a claude-code` targets Claude Code, and
45
- `--skill gloss` installs only the Gloss skill from the repo.
52
+ `--skill gloss` installs only the Gloss skill folder from the repo. The skill
53
+ teaches agents to run `gloss open --json`, wait for browser submission, read
54
+ `feedbackPath`, apply comments, validate, and mark the review resolved when MCP
55
+ tools are available.
46
56
 
47
- The hosted install script is npm-only:
57
+ The hosted install script remains npm-only:
48
58
 
49
59
  ```bash
50
60
  curl -fsSL https://getgloss.dev/install.sh | sh
@@ -78,16 +88,17 @@ keeps the old behavior and does not fall back to a branch diff.
78
88
  Completed reviews are written to:
79
89
 
80
90
  ```text
81
- <repo>/.gloss/reviews/<reviewId>/
91
+ ~/.gloss/reviews/<reviewId>/
82
92
  meta.json
83
93
  diff.json
84
94
  feedback.json
85
95
  feedback.md
86
- original/
96
+ resolved.json
87
97
  ```
88
98
 
89
99
  `feedback.json` is the machine-readable payload. `feedback.md` is a readable
90
- summary ordered by file and line.
100
+ summary ordered by file and line. Set `GLOSS_STATE_DIR` to use an isolated
101
+ state root for tests or development.
91
102
 
92
103
  ## MCP
93
104
 
@@ -122,10 +133,12 @@ Releases follow Willow's tag-driven shape:
122
133
  2. GitHub Actions runs checks, tests, and the production build.
123
134
  3. The package is published to npm as `getgloss`.
124
135
  4. A GitHub release is created with `npm pack` output and checksums.
136
+ 5. The Homebrew formula is updated in `iamrajjoshi/homebrew-tap`.
125
137
 
126
138
  Required repository secrets:
127
139
 
128
140
  - `NPM_TOKEN`
141
+ - `HOMEBREW_TAP_GITHUB_TOKEN`
129
142
 
130
143
  ## Attribution
131
144
 
package/dist/cli/index.js CHANGED
@@ -24,7 +24,7 @@ import path from "path";
24
24
  // package.json
25
25
  var package_default = {
26
26
  name: "getgloss",
27
- version: "0.2.0",
27
+ version: "0.3.0",
28
28
  description: "Local browser-based diff review for coding-agent loops.",
29
29
  type: "module",
30
30
  packageManager: "pnpm@10.33.2",
@@ -127,6 +127,27 @@ function globalLogDir() {
127
127
  function globalServerLogFile() {
128
128
  return path.join(globalLogDir(), "server.log");
129
129
  }
130
+ function globalReviewsDir() {
131
+ return path.join(globalStateDir(), "reviews");
132
+ }
133
+ function globalReviewDir(reviewId) {
134
+ return path.join(globalReviewsDir(), reviewId);
135
+ }
136
+ function globalReviewMetaFile(reviewId) {
137
+ return path.join(globalReviewDir(reviewId), "meta.json");
138
+ }
139
+ function globalReviewDiffFile(reviewId) {
140
+ return path.join(globalReviewDir(reviewId), "diff.json");
141
+ }
142
+ function globalReviewFeedbackFile(reviewId) {
143
+ return path.join(globalReviewDir(reviewId), "feedback.json");
144
+ }
145
+ function globalReviewMarkdownFile(reviewId) {
146
+ return path.join(globalReviewDir(reviewId), "feedback.md");
147
+ }
148
+ function globalReviewResolvedFile(reviewId) {
149
+ return path.join(globalReviewDir(reviewId), "resolved.json");
150
+ }
130
151
  async function ensureDir(dir) {
131
152
  await mkdir(dir, { recursive: true });
132
153
  }
@@ -717,6 +738,275 @@ async function assertGitAvailable() {
717
738
  await execa("git", ["--version"]);
718
739
  }
719
740
 
741
+ // src/server/store.ts
742
+ import { readdir, readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
743
+ import { ulid } from "ulid";
744
+
745
+ // src/shared/markdown.ts
746
+ function formatLineRange(comment) {
747
+ const prefix = comment.side;
748
+ if (comment.startLine === comment.endLine) {
749
+ return `${prefix}${comment.startLine}`;
750
+ }
751
+ return `${prefix}${comment.startLine}-${prefix}${comment.endLine}`;
752
+ }
753
+ function fenceFor(snippet) {
754
+ let fence = "```";
755
+ while (snippet.includes(fence)) {
756
+ fence += "`";
757
+ }
758
+ return fence;
759
+ }
760
+ function languageForPath2(filePath) {
761
+ const ext = filePath.split(".").pop()?.toLowerCase();
762
+ const map = {
763
+ cjs: "js",
764
+ css: "css",
765
+ go: "go",
766
+ html: "html",
767
+ js: "js",
768
+ json: "json",
769
+ jsx: "jsx",
770
+ md: "markdown",
771
+ mjs: "js",
772
+ py: "python",
773
+ rb: "ruby",
774
+ rs: "rust",
775
+ sh: "bash",
776
+ swift: "swift",
777
+ ts: "ts",
778
+ tsx: "tsx",
779
+ yaml: "yaml",
780
+ yml: "yaml"
781
+ };
782
+ return ext ? map[ext] ?? ext : "";
783
+ }
784
+ function languageForSnippet(filePath, snippet) {
785
+ const lines = snippet.split("\n").filter((line) => line.length > 0);
786
+ const looksLikeUnifiedDiff = lines.length > 0 && lines.some((line) => line.startsWith("+") || line.startsWith("-")) && lines.every((line) => line.startsWith("+") || line.startsWith("-") || line.startsWith(" "));
787
+ return looksLikeUnifiedDiff ? "diff" : languageForPath2(filePath);
788
+ }
789
+ function byFileThenLine(a, b) {
790
+ return a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine || a.endLine - b.endLine || a.side.localeCompare(b.side);
791
+ }
792
+ function serializeFeedbackMarkdown(bundle) {
793
+ const comments = [...bundle.comments].sort(byFileThenLine);
794
+ const files = [...new Set(comments.map((comment) => comment.filePath))];
795
+ const lines = [
796
+ `# Gloss feedback - ${bundle.timestamp}`,
797
+ `Review: ${bundle.reviewId}`,
798
+ `Base: ${bundle.base.ref} (${bundle.base.sha.slice(0, 7)}) Branch: ${bundle.branch ?? "(detached)"}`,
799
+ `Files: ${files.length} Comments: ${comments.length}`,
800
+ ""
801
+ ];
802
+ for (const filePath of files) {
803
+ lines.push(`## ${filePath}`, "");
804
+ for (const comment of comments.filter((item) => item.filePath === filePath)) {
805
+ const snippet = comment.originalSnippet.trimEnd();
806
+ const firstSnippetLine = snippet.split("\n").find((line) => line.trim().length > 0);
807
+ const heading = comment.startLine === comment.endLine && firstSnippetLine ? `### ${formatLineRange(comment)} - \`${firstSnippetLine.trim().slice(0, 80)}\`` : `### ${formatLineRange(comment)}`;
808
+ lines.push(heading, comment.body.trim(), "");
809
+ if (snippet) {
810
+ const fence = fenceFor(snippet);
811
+ lines.push(`${fence}${languageForSnippet(comment.filePath, snippet)}`, snippet, fence, "");
812
+ }
813
+ }
814
+ }
815
+ return `${lines.join("\n").trimEnd()}
816
+ `;
817
+ }
818
+
819
+ // src/server/store.ts
820
+ var ReviewStore = class {
821
+ reviews = /* @__PURE__ */ new Map();
822
+ listeners = /* @__PURE__ */ new Map();
823
+ async create(diff) {
824
+ const id = ulid();
825
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
826
+ const meta = {
827
+ id,
828
+ cwd: diff.cwd,
829
+ base: diff.base,
830
+ branch: diff.branch,
831
+ status: "pending",
832
+ createdAt,
833
+ artifactDir: globalReviewDir(id)
834
+ };
835
+ const record = { meta, diff };
836
+ this.reviews.set(id, record);
837
+ await this.persistInitial(record);
838
+ this.emit({ type: "review.opened", reviewId: id });
839
+ return record;
840
+ }
841
+ async list() {
842
+ await this.loadAllReviews();
843
+ return [...this.reviews.values()].map((record) => record.meta).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
844
+ }
845
+ async get(id) {
846
+ return this.reviews.get(id) ?? await this.loadKnownReview(id);
847
+ }
848
+ async submit(id, comments) {
849
+ const record = await this.get(id);
850
+ if (!record) {
851
+ throw new Error(`Review ${id} not found`);
852
+ }
853
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
854
+ const feedback = {
855
+ version: 1,
856
+ reviewId: id,
857
+ timestamp,
858
+ base: record.diff.base,
859
+ branch: record.diff.branch,
860
+ comments: [...comments].sort(
861
+ (a, b) => a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine || a.endLine - b.endLine || a.side.localeCompare(b.side)
862
+ )
863
+ };
864
+ record.feedback = feedback;
865
+ record.meta = { ...record.meta, status: "completed", completedAt: timestamp };
866
+ this.reviews.set(id, record);
867
+ const artifactDir = globalReviewDir(id);
868
+ const feedbackPath = globalReviewFeedbackFile(id);
869
+ const markdownPath = globalReviewMarkdownFile(id);
870
+ record.meta = {
871
+ ...record.meta,
872
+ artifactDir,
873
+ feedbackPath,
874
+ markdownPath
875
+ };
876
+ await ensureDir(artifactDir);
877
+ await Promise.all([
878
+ writeFile2(globalReviewMetaFile(id), `${JSON.stringify(record.meta, null, 2)}
879
+ `),
880
+ writeFile2(feedbackPath, `${JSON.stringify(feedback, null, 2)}
881
+ `),
882
+ writeFile2(markdownPath, serializeFeedbackMarkdown(feedback))
883
+ ]);
884
+ this.emit({
885
+ type: "review.completed",
886
+ reviewId: id,
887
+ counts: {
888
+ files: new Set(feedback.comments.map((comment) => comment.filePath)).size,
889
+ comments: feedback.comments.length
890
+ }
891
+ });
892
+ return { record, feedbackPath, markdownPath };
893
+ }
894
+ async feedback(id) {
895
+ const record = await this.get(id);
896
+ return record?.feedback ?? null;
897
+ }
898
+ async markResolved(id, summary) {
899
+ const record = await this.get(id);
900
+ if (!record) {
901
+ throw new Error(`Review ${id} not found`);
902
+ }
903
+ const resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
904
+ const resolvedPath = globalReviewResolvedFile(id);
905
+ record.meta = { ...record.meta, status: "resolved", resolvedAt };
906
+ this.reviews.set(id, record);
907
+ await ensureDir(globalReviewDir(id));
908
+ await writeFile2(
909
+ resolvedPath,
910
+ `${JSON.stringify({ reviewId: id, summary: summary ?? null, resolvedAt }, null, 2)}
911
+ `
912
+ );
913
+ await writeFile2(globalReviewMetaFile(id), `${JSON.stringify(record.meta, null, 2)}
914
+ `);
915
+ return resolvedPath;
916
+ }
917
+ subscribe(reviewId, listener) {
918
+ const listeners = this.listeners.get(reviewId) ?? /* @__PURE__ */ new Set();
919
+ listeners.add(listener);
920
+ this.listeners.set(reviewId, listeners);
921
+ return () => {
922
+ listeners.delete(listener);
923
+ if (listeners.size === 0) {
924
+ this.listeners.delete(reviewId);
925
+ }
926
+ };
927
+ }
928
+ emit(event) {
929
+ for (const listener of this.listeners.get(event.reviewId) ?? []) {
930
+ listener(event);
931
+ }
932
+ }
933
+ async persistInitial(record) {
934
+ const dir = globalReviewDir(record.meta.id);
935
+ await ensureDir(dir);
936
+ await Promise.all([
937
+ writeFile2(globalReviewMetaFile(record.meta.id), `${JSON.stringify(record.meta, null, 2)}
938
+ `),
939
+ writeFile2(globalReviewDiffFile(record.meta.id), `${JSON.stringify(record.diff, null, 2)}
940
+ `)
941
+ ]);
942
+ }
943
+ async loadKnownReview(id) {
944
+ const existing = this.reviews.get(id);
945
+ if (existing) {
946
+ return existing;
947
+ }
948
+ return this.loadReview(id);
949
+ }
950
+ async loadAllReviews() {
951
+ let entries;
952
+ try {
953
+ entries = await readdir(globalReviewsDir(), { withFileTypes: true });
954
+ } catch {
955
+ return;
956
+ }
957
+ await Promise.all(
958
+ entries.filter((entry) => entry.isDirectory()).map((entry) => this.loadReview(entry.name))
959
+ );
960
+ }
961
+ async loadReview(id) {
962
+ try {
963
+ const [metaRaw, diffRaw] = await Promise.all([
964
+ readFile2(globalReviewMetaFile(id), "utf8"),
965
+ readFile2(globalReviewDiffFile(id), "utf8")
966
+ ]);
967
+ const meta = JSON.parse(metaRaw);
968
+ const diff = JSON.parse(diffRaw);
969
+ let feedback;
970
+ try {
971
+ feedback = JSON.parse(
972
+ await readFile2(globalReviewFeedbackFile(id), "utf8")
973
+ );
974
+ } catch {
975
+ feedback = void 0;
976
+ }
977
+ const record = {
978
+ meta: {
979
+ ...meta,
980
+ artifactDir: meta.artifactDir ?? globalReviewDir(id),
981
+ feedbackPath: meta.feedbackPath ?? (feedback ? globalReviewFeedbackFile(id) : void 0),
982
+ markdownPath: meta.markdownPath ?? (feedback ? globalReviewMarkdownFile(id) : void 0)
983
+ },
984
+ diff,
985
+ feedback
986
+ };
987
+ this.reviews.set(id, record);
988
+ return record;
989
+ } catch {
990
+ return null;
991
+ }
992
+ }
993
+ };
994
+ var reviewStore = new ReviewStore();
995
+
996
+ // src/cli/status.ts
997
+ async function listReviewsForStatus({
998
+ responsive,
999
+ server
1000
+ }) {
1001
+ if (server && responsive) {
1002
+ try {
1003
+ return (await new ServerClient(serverUrl(server)).listReviews()).reviews;
1004
+ } catch {
1005
+ }
1006
+ }
1007
+ return new ReviewStore().list();
1008
+ }
1009
+
720
1010
  // src/cli/index.ts
721
1011
  function printJson(value) {
722
1012
  process.stdout.write(`${JSON.stringify(value, null, 2)}
@@ -742,7 +1032,13 @@ program.command("open").description("Capture local changes and open them for rev
742
1032
  await openBrowser(url);
743
1033
  }
744
1034
  if (options.watch === false) {
745
- const result2 = { reviewId: meta.id, url, files: diff.files.length, scope: diff.scope.mode };
1035
+ const result2 = {
1036
+ reviewId: meta.id,
1037
+ url,
1038
+ files: diff.files.length,
1039
+ scope: diff.scope.mode,
1040
+ artifactDir: meta.artifactDir
1041
+ };
746
1042
  globals.json ? printJson(result2) : printPlain(`Review ${meta.id}: ${url}`);
747
1043
  return;
748
1044
  }
@@ -761,8 +1057,9 @@ program.command("open").description("Capture local changes and open them for rev
761
1057
  url,
762
1058
  files: event.counts.files,
763
1059
  comments: event.counts.comments,
764
- feedbackPath: `${diff.cwd}/.gloss/reviews/${meta.id}/feedback.json`,
765
- markdownPath: `${diff.cwd}/.gloss/reviews/${meta.id}/feedback.md`,
1060
+ feedbackPath: globalReviewFeedbackFile(meta.id),
1061
+ markdownPath: globalReviewMarkdownFile(meta.id),
1062
+ artifactDir: globalReviewDir(meta.id),
766
1063
  feedback
767
1064
  };
768
1065
  globals.json ? printJson(result) : printPlain(`Review ${meta.id} completed with ${event.counts.comments} comments`);
@@ -784,10 +1081,7 @@ program.command("status").description("Show server and active reviews").action(a
784
1081
  const globals = program.opts();
785
1082
  const info = await readServerInfo();
786
1083
  const responsive = info ? await isServerResponsive(info) : false;
787
- let reviews = [];
788
- if (info && responsive) {
789
- reviews = (await new ServerClient(serverUrl(info)).listReviews()).reviews;
790
- }
1084
+ const reviews = await listReviewsForStatus({ responsive, server: info });
791
1085
  const status = { running: responsive, server: info, reviews };
792
1086
  globals.json ? printJson(status) : printPlain(
793
1087
  responsive && info ? `Gloss server running at ${serverUrl(info)} with ${reviews.length} active review(s)` : "Gloss server is not running"