@mepuka/skygent 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.
Files changed (49) hide show
  1. package/README.md +269 -31
  2. package/index.ts +18 -3
  3. package/package.json +1 -1
  4. package/src/cli/app.ts +4 -2
  5. package/src/cli/config.ts +20 -3
  6. package/src/cli/doc/table-renderers.ts +29 -0
  7. package/src/cli/doc/thread.ts +2 -4
  8. package/src/cli/exit-codes.ts +2 -0
  9. package/src/cli/feed.ts +35 -55
  10. package/src/cli/filter-dsl.ts +146 -11
  11. package/src/cli/filter-errors.ts +9 -3
  12. package/src/cli/filter-help.ts +7 -0
  13. package/src/cli/filter-input.ts +3 -2
  14. package/src/cli/filter.ts +84 -4
  15. package/src/cli/graph.ts +193 -156
  16. package/src/cli/input.ts +45 -0
  17. package/src/cli/layers.ts +10 -0
  18. package/src/cli/logging.ts +8 -0
  19. package/src/cli/output-render.ts +14 -0
  20. package/src/cli/pagination.ts +18 -0
  21. package/src/cli/parse-errors.ts +18 -0
  22. package/src/cli/pipe.ts +157 -0
  23. package/src/cli/post.ts +43 -66
  24. package/src/cli/query.ts +349 -74
  25. package/src/cli/search.ts +92 -118
  26. package/src/cli/shared.ts +0 -19
  27. package/src/cli/store-errors.ts +24 -13
  28. package/src/cli/store-tree.ts +6 -4
  29. package/src/cli/store.ts +35 -2
  30. package/src/cli/stream-merge.ts +105 -0
  31. package/src/cli/sync-factory.ts +28 -3
  32. package/src/cli/sync.ts +16 -18
  33. package/src/cli/thread-options.ts +33 -0
  34. package/src/cli/time.ts +171 -0
  35. package/src/cli/view-thread.ts +12 -18
  36. package/src/cli/watch.ts +61 -19
  37. package/src/domain/errors.ts +6 -1
  38. package/src/domain/format.ts +21 -0
  39. package/src/domain/order.ts +24 -0
  40. package/src/graph/relationships.ts +129 -0
  41. package/src/services/jetstream-sync.ts +4 -4
  42. package/src/services/lineage-store.ts +15 -1
  43. package/src/services/store-commit.ts +60 -0
  44. package/src/services/store-manager.ts +69 -2
  45. package/src/services/store-renamer.ts +286 -0
  46. package/src/services/store-stats.ts +7 -5
  47. package/src/services/sync-engine.ts +136 -85
  48. package/src/services/sync-reporter.ts +3 -1
  49. package/src/services/sync-settings.ts +24 -0
@@ -0,0 +1,157 @@
1
+ import { Command, Options } from "@effect/cli";
2
+ import { Chunk, Effect, Option, Ref, Stream } from "effect";
3
+ import { ParseResult } from "effect";
4
+ import { RawPost } from "../domain/raw.js";
5
+ import type { Post } from "../domain/post.js";
6
+ import { FilterRuntime } from "../services/filter-runtime.js";
7
+ import { PostParser } from "../services/post-parser.js";
8
+ import { CliInput } from "./input.js";
9
+ import { CliInputError, CliJsonError } from "./errors.js";
10
+ import { parseFilterExpr } from "./filter-input.js";
11
+ import { decodeJson } from "./parse.js";
12
+ import { withExamples } from "./help.js";
13
+ import { filterOption, filterJsonOption } from "./shared-options.js";
14
+ import { formatSchemaError } from "./shared.js";
15
+ import { writeJsonStream, writeText } from "./output.js";
16
+ import { filterByFlags } from "../typeclass/chunk.js";
17
+ import { logErrorEvent, logWarn } from "./logging.js";
18
+
19
+ const onErrorOption = Options.choice("on-error", ["fail", "skip", "report"]).pipe(
20
+ Options.withDescription("Behavior on invalid input lines"),
21
+ Options.withDefault("fail" as const)
22
+ );
23
+
24
+ const batchSizeOption = Options.integer("batch-size").pipe(
25
+ Options.withDescription("Posts per filter batch (default: 50)"),
26
+ Options.optional
27
+ );
28
+
29
+ const requireFilterExpr = (
30
+ filter: Option.Option<string>,
31
+ filterJson: Option.Option<string>
32
+ ) =>
33
+ Option.isNone(filter) && Option.isNone(filterJson)
34
+ ? Effect.fail(
35
+ CliInputError.make({
36
+ message: "Provide --filter or --filter-json.",
37
+ cause: { filter: null, filterJson: null }
38
+ })
39
+ )
40
+ : Effect.void;
41
+
42
+ const truncate = (value: string, max = 500) =>
43
+ value.length > max ? `${value.slice(0, max)}...` : value;
44
+
45
+ const formatPipeError = (error: unknown) => {
46
+ if (error instanceof CliJsonError || error instanceof CliInputError) {
47
+ return error.message;
48
+ }
49
+ if (ParseResult.isParseError(error)) {
50
+ return formatSchemaError(error);
51
+ }
52
+ if (typeof error === "object" && error !== null && "message" in error) {
53
+ const message = (error as { readonly message?: unknown }).message;
54
+ if (typeof message === "string" && message.length > 0) {
55
+ return message;
56
+ }
57
+ }
58
+ return String(error);
59
+ };
60
+
61
+ export const pipeCommand = Command.make(
62
+ "pipe",
63
+ { filter: filterOption, filterJson: filterJsonOption, onError: onErrorOption, batchSize: batchSizeOption },
64
+ ({ filter, filterJson, onError, batchSize }) =>
65
+ Effect.gen(function* () {
66
+ if (process.stdin.isTTY) {
67
+ return yield* CliInputError.make({
68
+ message: "stdin is a TTY. Pipe NDJSON input into skygent pipe.",
69
+ cause: { isTTY: true }
70
+ });
71
+ }
72
+ yield* requireFilterExpr(filter, filterJson);
73
+
74
+ const input = yield* CliInput;
75
+ const parser = yield* PostParser;
76
+ const runtime = yield* FilterRuntime;
77
+ const expr = yield* parseFilterExpr(filter, filterJson);
78
+ const evaluateBatch = yield* runtime.evaluateBatch(expr);
79
+
80
+ const size = Option.getOrElse(batchSize, () => 50);
81
+ if (size <= 0) {
82
+ return yield* CliInputError.make({
83
+ message: "--batch-size must be a positive integer.",
84
+ cause: { batchSize: size }
85
+ });
86
+ }
87
+
88
+ const lineRef = yield* Ref.make(0);
89
+ const countRef = yield* Ref.make(0);
90
+
91
+ const parsed = input.lines.pipe(
92
+ Stream.map((line) => line.trim()),
93
+ Stream.filter((line) => line.length > 0),
94
+ Stream.mapEffect((line) =>
95
+ Ref.updateAndGet(lineRef, (value) => value + 1).pipe(
96
+ Effect.map((lineNumber) => ({ line, lineNumber }))
97
+ )
98
+ ),
99
+ Stream.mapEffect(({ line, lineNumber }) =>
100
+ decodeJson(RawPost, line).pipe(
101
+ Effect.flatMap((raw) => parser.parsePost(raw)),
102
+ Effect.map(Option.some),
103
+ Effect.catchAll((error) => {
104
+ if (onError === "fail") {
105
+ return Effect.fail(error);
106
+ }
107
+ const message = formatPipeError(error);
108
+ const payload = {
109
+ line: lineNumber,
110
+ message,
111
+ input: truncate(line)
112
+ };
113
+ const log =
114
+ onError === "report"
115
+ ? logErrorEvent("Invalid input line", payload)
116
+ : logWarn("Skipping invalid input line", payload);
117
+ return log.pipe(
118
+ Effect.ignore,
119
+ Effect.as(Option.none<Post>())
120
+ );
121
+ })
122
+ )
123
+ ),
124
+ Stream.filterMap((value) => value)
125
+ );
126
+
127
+ const filtered = parsed.pipe(
128
+ Stream.grouped(size),
129
+ Stream.mapEffect((batch) =>
130
+ evaluateBatch(batch).pipe(
131
+ Effect.map((flags) => filterByFlags(batch, flags))
132
+ )
133
+ ),
134
+ Stream.mapConcat((chunk) => Chunk.toReadonlyArray(chunk)),
135
+ Stream.tap(() => Ref.update(countRef, (count) => count + 1))
136
+ );
137
+
138
+ yield* writeJsonStream(filtered);
139
+ const count = yield* Ref.get(countRef);
140
+ if (count === 0) {
141
+ yield* writeText("[]");
142
+ }
143
+ })
144
+ ).pipe(
145
+ Command.withDescription(
146
+ withExamples(
147
+ "Filter raw post NDJSON from stdin",
148
+ [
149
+ "skygent pipe --filter 'hashtag:#ai' < posts.ndjson",
150
+ "cat posts.ndjson | skygent pipe --filter-json '{\"_tag\":\"All\"}'"
151
+ ],
152
+ [
153
+ "Note: stdin must be raw post NDJSON (app.bsky.feed.getPosts result)."
154
+ ]
155
+ )
156
+ )
157
+ );
package/src/cli/post.ts CHANGED
@@ -2,24 +2,25 @@ import { Command, Options } from "@effect/cli";
2
2
  import { Context, Effect, Option, Stream } from "effect";
3
3
  import { BskyClient } from "../services/bsky-client.js";
4
4
  import { PostParser } from "../services/post-parser.js";
5
- import type { PostLike, ProfileView } from "../domain/bsky.js";
5
+ import type { PostLike } from "../domain/bsky.js";
6
6
  import type { RawPost } from "../domain/raw.js";
7
7
  import { renderPostsTable } from "../domain/format.js";
8
8
  import { AppConfigService } from "../services/app-config.js";
9
9
  import { withExamples } from "./help.js";
10
- import { postUriArg, parseLimit } from "./shared-options.js";
10
+ import { postUriArg } from "./shared-options.js";
11
11
  import { writeJson, writeJsonStream, writeText } from "./output.js";
12
12
  import { renderTableLegacy } from "./doc/table.js";
13
- import { jsonNdjsonTableFormats, resolveOutputFormat } from "./output-format.js";
13
+ import { renderProfileTable } from "./doc/table-renderers.js";
14
+ import { jsonNdjsonTableFormats } from "./output-format.js";
15
+ import { emitWithFormat } from "./output-render.js";
16
+ import { cursorOption as baseCursorOption, limitOption as baseLimitOption, parsePagination } from "./pagination.js";
14
17
 
15
- const limitOption = Options.integer("limit").pipe(
16
- Options.withDescription("Maximum number of results"),
17
- Options.optional
18
+ const limitOption = baseLimitOption.pipe(
19
+ Options.withDescription("Maximum number of results")
18
20
  );
19
21
 
20
- const cursorOption = Options.text("cursor").pipe(
21
- Options.withDescription("Pagination cursor"),
22
- Options.optional
22
+ const cursorOption = baseCursorOption.pipe(
23
+ Options.withDescription("Pagination cursor")
23
24
  );
24
25
 
25
26
  const formatOption = Options.choice("format", jsonNdjsonTableFormats).pipe(
@@ -32,18 +33,6 @@ const cidOption = Options.text("cid").pipe(
32
33
  Options.optional
33
34
  );
34
35
 
35
- const renderProfileTable = (
36
- actors: ReadonlyArray<ProfileView>,
37
- cursor: string | undefined
38
- ) => {
39
- const rows = actors.map((actor) => [
40
- actor.handle,
41
- actor.displayName ?? "",
42
- actor.did
43
- ]);
44
- const table = renderTableLegacy(["HANDLE", "DISPLAY NAME", "DID"], rows);
45
- return cursor ? `${table}\n\nCursor: ${cursor}` : table;
46
- };
47
36
 
48
37
  const renderLikesTable = (likes: ReadonlyArray<PostLike>, cursor: string | undefined) => {
49
38
  const rows = likes.map((like) => [
@@ -72,27 +61,23 @@ const likesCommand = Command.make(
72
61
  Effect.gen(function* () {
73
62
  const appConfig = yield* AppConfigService;
74
63
  const client = yield* BskyClient;
75
- const parsedLimit = yield* parseLimit(limit);
64
+ const { limit: limitValue, cursor: cursorValue } = yield* parsePagination(limit, cursor);
76
65
  const result = yield* client.getLikes(uri, {
77
- ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
78
- ...(Option.isSome(cursor) ? { cursor: cursor.value } : {}),
66
+ ...(limitValue !== undefined ? { limit: limitValue } : {}),
67
+ ...(cursorValue !== undefined ? { cursor: cursorValue } : {}),
79
68
  ...(Option.isSome(cid) ? { cid: cid.value } : {})
80
69
  });
81
- const outputFormat = resolveOutputFormat(
70
+ yield* emitWithFormat(
82
71
  format,
83
72
  appConfig.outputFormat,
84
73
  jsonNdjsonTableFormats,
85
- "json"
74
+ "json",
75
+ {
76
+ json: writeJson(result),
77
+ ndjson: writeJsonStream(Stream.fromIterable(result.likes)),
78
+ table: writeText(renderLikesTable(result.likes, result.cursor))
79
+ }
86
80
  );
87
- if (outputFormat === "ndjson") {
88
- yield* writeJsonStream(Stream.fromIterable(result.likes));
89
- return;
90
- }
91
- if (outputFormat === "table") {
92
- yield* writeText(renderLikesTable(result.likes, result.cursor));
93
- return;
94
- }
95
- yield* writeJson(result);
96
81
  })
97
82
  ).pipe(
98
83
  Command.withDescription(
@@ -110,27 +95,23 @@ const repostedByCommand = Command.make(
110
95
  Effect.gen(function* () {
111
96
  const appConfig = yield* AppConfigService;
112
97
  const client = yield* BskyClient;
113
- const parsedLimit = yield* parseLimit(limit);
98
+ const { limit: limitValue, cursor: cursorValue } = yield* parsePagination(limit, cursor);
114
99
  const result = yield* client.getRepostedBy(uri, {
115
- ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
116
- ...(Option.isSome(cursor) ? { cursor: cursor.value } : {}),
100
+ ...(limitValue !== undefined ? { limit: limitValue } : {}),
101
+ ...(cursorValue !== undefined ? { cursor: cursorValue } : {}),
117
102
  ...(Option.isSome(cid) ? { cid: cid.value } : {})
118
103
  });
119
- const outputFormat = resolveOutputFormat(
104
+ yield* emitWithFormat(
120
105
  format,
121
106
  appConfig.outputFormat,
122
107
  jsonNdjsonTableFormats,
123
- "json"
108
+ "json",
109
+ {
110
+ json: writeJson(result),
111
+ ndjson: writeJsonStream(Stream.fromIterable(result.repostedBy)),
112
+ table: writeText(renderProfileTable(result.repostedBy, result.cursor))
113
+ }
124
114
  );
125
- if (outputFormat === "ndjson") {
126
- yield* writeJsonStream(Stream.fromIterable(result.repostedBy));
127
- return;
128
- }
129
- if (outputFormat === "table") {
130
- yield* writeText(renderProfileTable(result.repostedBy, result.cursor));
131
- return;
132
- }
133
- yield* writeJson(result);
134
115
  })
135
116
  ).pipe(
136
117
  Command.withDescription(
@@ -148,31 +129,27 @@ const quotesCommand = Command.make(
148
129
  const appConfig = yield* AppConfigService;
149
130
  const client = yield* BskyClient;
150
131
  const parser = yield* PostParser;
151
- const parsedLimit = yield* parseLimit(limit);
132
+ const { limit: limitValue, cursor: cursorValue } = yield* parsePagination(limit, cursor);
152
133
  const result = yield* client.getQuotes(uri, {
153
- ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
154
- ...(Option.isSome(cursor) ? { cursor: cursor.value } : {}),
134
+ ...(limitValue !== undefined ? { limit: limitValue } : {}),
135
+ ...(cursorValue !== undefined ? { cursor: cursorValue } : {}),
155
136
  ...(Option.isSome(cid) ? { cid: cid.value } : {})
156
137
  });
157
138
  const posts = yield* parseRawPosts(parser, result.posts);
158
- const outputFormat = resolveOutputFormat(
139
+ yield* emitWithFormat(
159
140
  format,
160
141
  appConfig.outputFormat,
161
142
  jsonNdjsonTableFormats,
162
- "json"
143
+ "json",
144
+ {
145
+ json: writeJson({
146
+ ...result,
147
+ posts
148
+ }),
149
+ ndjson: writeJsonStream(Stream.fromIterable(posts)),
150
+ table: writeText(renderPostsTable(posts))
151
+ }
163
152
  );
164
- if (outputFormat === "ndjson") {
165
- yield* writeJsonStream(Stream.fromIterable(posts));
166
- return;
167
- }
168
- if (outputFormat === "table") {
169
- yield* writeText(renderPostsTable(posts));
170
- return;
171
- }
172
- yield* writeJson({
173
- ...result,
174
- posts
175
- });
176
153
  })
177
154
  ).pipe(
178
155
  Command.withDescription(