pray-cli 1.6.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 (71) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/LICENSE.md +21 -0
  4. data/README.md +125 -0
  5. data/SECURITY.md +28 -0
  6. data/bin/polyrun +108 -0
  7. data/bin/pray +26 -0
  8. data/lib/pray/archive.rb +68 -0
  9. data/lib/pray/auth_client.rb +97 -0
  10. data/lib/pray/cli/commands/auth.rb +42 -0
  11. data/lib/pray/cli/commands/distribution.rb +17 -0
  12. data/lib/pray/cli/commands/init.rb +62 -0
  13. data/lib/pray/cli/commands/meta.rb +15 -0
  14. data/lib/pray/cli/commands/packages.rb +107 -0
  15. data/lib/pray/cli/commands/trust.rb +73 -0
  16. data/lib/pray/cli/commands/workflow.rb +126 -0
  17. data/lib/pray/cli/help.rb +168 -0
  18. data/lib/pray/cli/helpers.rb +166 -0
  19. data/lib/pray/cli/parse.rb +136 -0
  20. data/lib/pray/cli/parse_auth.rb +91 -0
  21. data/lib/pray/cli/parse_trust.rb +152 -0
  22. data/lib/pray/cli/suggest.rb +57 -0
  23. data/lib/pray/cli.rb +117 -0
  24. data/lib/pray/confess.rb +113 -0
  25. data/lib/pray/config.rb +52 -0
  26. data/lib/pray/constraint.rb +80 -0
  27. data/lib/pray/destination.rb +158 -0
  28. data/lib/pray/dotenv.rb +46 -0
  29. data/lib/pray/environment.rb +41 -0
  30. data/lib/pray/error.rb +55 -0
  31. data/lib/pray/format_manifest.rb +255 -0
  32. data/lib/pray/format_serialize.rb +135 -0
  33. data/lib/pray/git_sources.rb +228 -0
  34. data/lib/pray/hashing.rb +59 -0
  35. data/lib/pray/invocation.rb +110 -0
  36. data/lib/pray/literal.rb +382 -0
  37. data/lib/pray/lockfile.rb +259 -0
  38. data/lib/pray/lockfile_serialize.rb +125 -0
  39. data/lib/pray/manifest.rb +126 -0
  40. data/lib/pray/manifest_formatter.rb +56 -0
  41. data/lib/pray/manifest_json.rb +128 -0
  42. data/lib/pray/manifest_parser.rb +152 -0
  43. data/lib/pray/manifest_parser_blocks.rb +216 -0
  44. data/lib/pray/manifest_parser_helpers.rb +208 -0
  45. data/lib/pray/materialize.rb +120 -0
  46. data/lib/pray/package_spec.rb +245 -0
  47. data/lib/pray/path_safety.rb +41 -0
  48. data/lib/pray/plan.rb +83 -0
  49. data/lib/pray/project_context.rb +67 -0
  50. data/lib/pray/publish.rb +162 -0
  51. data/lib/pray/registry.rb +355 -0
  52. data/lib/pray/render.rb +360 -0
  53. data/lib/pray/resolve.rb +396 -0
  54. data/lib/pray/resolve_context.rb +19 -0
  55. data/lib/pray/serve.rb +130 -0
  56. data/lib/pray/serve_federation.rb +109 -0
  57. data/lib/pray/session.rb +90 -0
  58. data/lib/pray/ssh_agent.rb +115 -0
  59. data/lib/pray/statement_surface.rb +262 -0
  60. data/lib/pray/substitute.rb +50 -0
  61. data/lib/pray/sync.rb +219 -0
  62. data/lib/pray/terminal.rb +30 -0
  63. data/lib/pray/trust.rb +201 -0
  64. data/lib/pray/trust_feed.rb +110 -0
  65. data/lib/pray/trust_ops.rb +202 -0
  66. data/lib/pray/verify.rb +257 -0
  67. data/lib/pray/version.rb +6 -0
  68. data/lib/pray-cli.rb +4 -0
  69. data/lib/pray.rb +46 -0
  70. data/pray-cli.gemspec +48 -0
  71. metadata +187 -0
@@ -0,0 +1,360 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Pray
7
+ RenderedTarget = Struct.new(:path, :content, :managed_spans) do
8
+ def initialize(path:, content:, managed_spans: [])
9
+ super
10
+ end
11
+ end
12
+
13
+ module Render
14
+ module_function
15
+
16
+ def render_project(project)
17
+ rendered = []
18
+ project.manifest.targets.each do |target|
19
+ output = target.outputs.first
20
+ next unless output
21
+
22
+ rendered << render_target(project, target, output)
23
+ end
24
+ rendered
25
+ end
26
+
27
+ def write_rendered_targets(project, rendered)
28
+ rendered.each do |target|
29
+ path = File.join(project.project_root, target.path)
30
+ FileUtils.mkdir_p(File.dirname(path))
31
+ File.write(path, target.content)
32
+ end
33
+ materialize_provisioned_exports(project)
34
+ end
35
+
36
+ def materialize_provisioned_exports(project)
37
+ symbols = project.manifest.symbols || {}
38
+ planned_provisioned_files(project).each do |file|
39
+ destination = File.join(project.project_root, file.path)
40
+ FileUtils.mkdir_p(File.dirname(destination))
41
+ write_provisioned_file(file.source, destination, symbols)
42
+ end
43
+ end
44
+
45
+ def write_provisioned_file(source, destination, symbols)
46
+ bytes = File.binread(source)
47
+ text = bytes.force_encoding(Encoding::UTF_8)
48
+ if text.valid_encoding?
49
+ File.write(destination, Substitute.substitute_pray_symbols(text, symbols))
50
+ else
51
+ File.binwrite(destination, bytes)
52
+ end
53
+ end
54
+
55
+ def planned_provisioned_files(project)
56
+ planned = []
57
+ collect_exact_file_bindings(project, planned)
58
+ project.manifest.targets.each do |target|
59
+ target.skills.each do |folder_root|
60
+ destination_root = File.join(project.project_root, folder_root)
61
+ project.packages.each do |package|
62
+ next unless Environment.package_matches_environment?(package.declaration.groups, project.environment)
63
+ next unless Destination.package_bound_to_tree?(package.declaration, target)
64
+
65
+ collect_legacy_skill_files(project, package, destination_root, planned)
66
+ collect_selected_export_files(project, package, destination_root, planned)
67
+ end
68
+ end
69
+ end
70
+ planned.sort_by(&:path).uniq(&:path)
71
+ end
72
+
73
+ PlannedProvisionedFile = Struct.new(:path, :source)
74
+
75
+ def render_target(project, target, output)
76
+ if target.scoped && target.mode == "compose"
77
+ return render_scoped_compose(project, target, output)
78
+ end
79
+
80
+ render_legacy_compose(project, target, output)
81
+ end
82
+
83
+ def render_scoped_compose(project, target, output)
84
+ builder = ContentBuilder.new
85
+ append_header(builder, project, output)
86
+ symbols = project.manifest.symbols || {}
87
+ managed_spans = []
88
+
89
+ (target.entries || []).each do |entry|
90
+ if entry.kind == "local"
91
+ append_scoped_local(builder, project, entry.path, symbols)
92
+ else
93
+ append_scoped_package(builder, managed_spans, project, target, output, entry.name, symbols)
94
+ end
95
+ end
96
+
97
+ RenderedTarget.new(path: output, content: builder.finish, managed_spans: managed_spans)
98
+ end
99
+
100
+ def render_legacy_compose(project, target, output)
101
+ builder = ContentBuilder.new
102
+ append_header(builder, project, output)
103
+
104
+ unbound_locals = project.local_files.select do |local|
105
+ declaration = project.manifest.local.find { |entry| entry.path == local.manifest_path }
106
+ declaration.nil? || !declaration.bound
107
+ end
108
+
109
+ unless unbound_locals.empty?
110
+ builder.append_line("## Additional instructions")
111
+ builder.append_empty_line
112
+ end
113
+ symbols = project.manifest.symbols || {}
114
+ unbound_locals.each do |local|
115
+ next if local.content.empty? && local.optional
116
+
117
+ builder.append_line("### #{local.manifest_path}")
118
+ builder.append_body(Substitute.substitute_pray_symbols(local.content, symbols))
119
+ builder.append_empty_line
120
+ end
121
+
122
+ builder.append_line("## Shared instructions")
123
+ builder.append_empty_line
124
+
125
+ managed_spans = []
126
+ project.packages.each do |package|
127
+ next unless Environment.package_matches_environment?(package.declaration.groups, project.environment)
128
+ next unless Destination.package_bound_to_compose?(package.declaration, target)
129
+
130
+ package.selected_exports.each do |export|
131
+ next unless should_inline_export?(package, export)
132
+
133
+ append_managed_export(builder, managed_spans, package, export, target, output, symbols)
134
+ end
135
+ end
136
+
137
+ RenderedTarget.new(path: output, content: builder.finish, managed_spans: managed_spans)
138
+ end
139
+
140
+ def append_header(builder, project, output)
141
+ return unless project.manifest.render.header
142
+
143
+ output_name = File.basename(output)
144
+ builder.append_line("<!-- pray:0 ignore-comments -->")
145
+ builder.append_empty_line
146
+ builder.append_line("# Agent context")
147
+ builder.append_empty_line
148
+ builder.append_line("Do not edit managed blocks in `#{output_name}` or provisioned files under `.agents/`.")
149
+ builder.append_line("To change shared guidance, update `Prayfile` and run `pray install`.")
150
+ builder.append_empty_line
151
+ end
152
+
153
+ def append_scoped_local(builder, project, path, symbols)
154
+ local = project.local_files.find { |candidate| candidate.manifest_path == path }
155
+ return unless local
156
+ return if local.content.empty? && local.optional
157
+
158
+ builder.append_body(Substitute.substitute_pray_symbols(local.content, symbols))
159
+ builder.append_empty_line
160
+ end
161
+
162
+ def append_scoped_package(builder, managed_spans, project, target, output, package_name, symbols)
163
+ package = project.packages.find { |candidate| candidate.declaration.name == package_name }
164
+ return unless package
165
+ return unless Environment.package_matches_environment?(package.declaration.groups, project.environment)
166
+
167
+ package.selected_exports.each do |export|
168
+ next unless should_inline_export?(package, export)
169
+
170
+ append_managed_export(builder, managed_spans, package, export, target, output, symbols)
171
+ end
172
+ end
173
+
174
+ def append_managed_export(builder, managed_spans, package, export, target, output, symbols)
175
+ body = package.export_bodies[export]
176
+ raise Error.render("package #{package.declaration.name} is missing cached export #{export}") unless body
177
+
178
+ body = Substitute.substitute_pray_symbols(body, symbols)
179
+ identifier = Hashing.marker_id("#{package.declaration.name}:#{export}:#{target.name}")
180
+ open_line = builder.next_line_number
181
+ builder.append_line("<!-- pray:#{identifier} -->")
182
+ builder.append_body(body)
183
+ close_line = builder.next_line_number
184
+ builder.append_line("<!-- pray:#{identifier} -->")
185
+ managed_spans << ManagedSpanRecord.new(
186
+ id: identifier,
187
+ target: output,
188
+ open_line: open_line,
189
+ close_line: close_line,
190
+ ideal_checksum: Hashing.checksum_managed_span_content(body),
191
+ package: package.declaration.name,
192
+ export: export,
193
+ source_checksum: package.source_checksum,
194
+ silenced: false
195
+ )
196
+ builder.append_empty_line
197
+ end
198
+
199
+ def should_inline_export?(package, export_name)
200
+ export = package.spec.exports[export_name]
201
+ export.nil? || export.kind == "fragment"
202
+ end
203
+
204
+ def collect_exact_file_bindings(project, planned)
205
+ project.packages.each do |package|
206
+ destination = package.declaration.file
207
+ next unless destination
208
+ next unless Environment.package_matches_environment?(package.declaration.groups, project.environment)
209
+
210
+ matched = false
211
+ package.selected_exports.each do |export_name|
212
+ export = package.spec.exports[export_name]
213
+ next unless export&.kind == "file"
214
+
215
+ source = File.join(package.root, export.path)
216
+ raise Error.render("file export source missing: #{source}") unless File.file?(source)
217
+
218
+ planned << PlannedProvisionedFile.new(path: destination, source: source)
219
+ matched = true
220
+ break
221
+ end
222
+ unless matched
223
+ raise Error.render(
224
+ "package #{package.declaration.name} has file: \"#{destination}\" but no selected file export"
225
+ )
226
+ end
227
+ end
228
+ end
229
+
230
+ def collect_legacy_skill_files(project, package, destination_root, planned)
231
+ package.spec.skills.each do |skill_name, skill|
232
+ next if legacy_skill_covered_by_export?(package, skill)
233
+
234
+ skill_files = package.skill_files[skill_name]
235
+ raise Error.render("package #{package.declaration.name} has no indexed files for legacy skill #{skill_name}") unless skill_files
236
+
237
+ collect_tree_files(
238
+ project,
239
+ File.join(package.root, skill.path),
240
+ File.join(destination_root, skill_name),
241
+ skill_files,
242
+ [],
243
+ [],
244
+ planned
245
+ )
246
+ end
247
+ end
248
+
249
+ def legacy_skill_covered_by_export?(package, skill)
250
+ package.spec.exports.any? do |export_name, export|
251
+ package.selected_exports.include?(export_name) &&
252
+ %w[folder skill].include?(export.kind) &&
253
+ export.path.delete_suffix("/") == skill.path.delete_suffix("/")
254
+ end
255
+ end
256
+
257
+ def collect_selected_export_files(project, package, destination_root, planned)
258
+ package.selected_exports.each do |export_name|
259
+ export = package.spec.exports[export_name]
260
+ next unless export
261
+
262
+ case export.kind
263
+ when "folder", "skill"
264
+ indexed_files = package.skill_files[export_name]
265
+ unless indexed_files
266
+ raise Error.render("package #{package.declaration.name} has no indexed files for folder export #{export_name}")
267
+ end
268
+
269
+ destination_name = folder_destination_name(export_name, export.path)
270
+ collect_tree_files(
271
+ project,
272
+ File.join(package.root, export.path),
273
+ File.join(destination_root, destination_name),
274
+ indexed_files,
275
+ export.only || [],
276
+ export.except || [],
277
+ planned
278
+ )
279
+ when "file"
280
+ next if package.declaration.file
281
+
282
+ source = File.join(package.root, export.path)
283
+ raise Error.render("file export source missing: #{source}") unless File.file?(source)
284
+
285
+ destination = File.join(destination_root, export_name, File.basename(source))
286
+ planned << PlannedProvisionedFile.new(
287
+ path: relative_project_path(project, destination),
288
+ source: source
289
+ )
290
+ end
291
+ end
292
+ end
293
+
294
+ def folder_destination_name(export_name, export_path)
295
+ File.basename(export_path.delete_suffix("/")).empty? ? export_name : File.basename(export_path.delete_suffix("/"))
296
+ end
297
+
298
+ def collect_tree_files(project, source_root, destination_root, relative_files, only, except, planned)
299
+ raise Error.render("folder source directory missing: #{source_root}") unless File.directory?(source_root)
300
+ raise Error.render("no files listed in package manifest for #{source_root}") if relative_files.empty?
301
+
302
+ matched = false
303
+ relative_files.each do |relative|
304
+ next if !only.empty? && !only.include?(relative)
305
+ next if except.include?(relative)
306
+
307
+ source = File.join(source_root, relative)
308
+ raise Error.render("provisioned file missing: #{source}") unless File.file?(source)
309
+
310
+ destination = File.join(destination_root, relative)
311
+ planned << PlannedProvisionedFile.new(
312
+ path: relative_project_path(project, destination),
313
+ source: source
314
+ )
315
+ matched = true
316
+ end
317
+
318
+ if !matched && only.empty? && except.empty?
319
+ raise Error.render("no files listed in package manifest for #{source_root}")
320
+ end
321
+ end
322
+
323
+ def relative_project_path(project, absolute)
324
+ Pathname(absolute).relative_path_from(Pathname(project.project_root)).to_s
325
+ rescue ArgumentError
326
+ absolute
327
+ end
328
+
329
+ class ContentBuilder
330
+ def initialize
331
+ @content = +""
332
+ end
333
+
334
+ def next_line_number
335
+ @content.count("\n") + 1
336
+ end
337
+
338
+ def append_line(line)
339
+ @content << line << "\n"
340
+ end
341
+
342
+ def append_empty_line
343
+ @content << "\n"
344
+ end
345
+
346
+ def append_body(body)
347
+ trimmed = body.sub(/\n+\z/, "")
348
+ return if trimmed.empty?
349
+
350
+ trimmed.each_line(chomp: true) { |line| append_line(line) }
351
+ end
352
+
353
+ def finish
354
+ @content.sub(/\n\n+\z/, "\n")
355
+ @content << "\n" unless @content.end_with?("\n")
356
+ @content
357
+ end
358
+ end
359
+ end
360
+ end