lcp 0.1.0 → 0.2.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/lcp-custom-field/SKILL.md +10 -1
  3. data/.claude/skills/lcp-custom-field/agents/openai.yaml +4 -0
  4. data/.claude/skills/lcp-getting-started/SKILL.md +1 -1
  5. data/.claude/skills/lcp-getting-started/agents/openai.yaml +4 -0
  6. data/.claude/skills/lcp-host-binding/agents/openai.yaml +4 -0
  7. data/.claude/skills/lcp-model/agents/openai.yaml +4 -0
  8. data/.claude/skills/lcp-permissions/agents/openai.yaml +4 -0
  9. data/.claude/skills/lcp-presenter/agents/openai.yaml +4 -0
  10. data/.claude/skills/lcp-workflow/SKILL.md +10 -2
  11. data/.claude/skills/lcp-workflow/agents/openai.yaml +4 -0
  12. data/CHANGELOG.md +59 -1
  13. data/app/assets/javascripts/lcp_ruby/dev_toolbar.js +5 -6
  14. data/app/controllers/concerns/lcp_ruby/zone_resolution.rb +14 -1
  15. data/app/helpers/lcp_ruby/display/card_helper.rb +71 -16
  16. data/app/helpers/lcp_ruby/display_helper.rb +30 -3
  17. data/app/helpers/lcp_ruby/display_template_helper.rb +3 -3
  18. data/app/helpers/lcp_ruby/layout_helper.rb +49 -8
  19. data/app/helpers/lcp_ruby/tree_helper.rb +24 -3
  20. data/app/models/lcp_ruby/user.rb +10 -0
  21. data/app/views/layouts/lcp_ruby/application.html.erb +42 -20
  22. data/app/views/layouts/lcp_ruby/auth.html.erb +6 -1
  23. data/app/views/lcp_ruby/resources/_show_sections.html.erb +24 -17
  24. data/app/views/lcp_ruby/resources/_table_index.html.erb +12 -17
  25. data/app/views/lcp_ruby/widgets/_markdown.html.erb +13 -0
  26. data/app/views/lcp_ruby/widgets/_presenter_zone.html.erb +23 -7
  27. data/app/views/lcp_ruby/widgets/_rich_text.html.erb +25 -0
  28. data/docs/README.md +3 -0
  29. data/docs/feature-catalog.md +5 -2
  30. data/docs/feature-catalog.yml +89 -9
  31. data/docs/getting-started.md +38 -22
  32. data/docs/guides/dashboards.md +44 -1
  33. data/docs/guides/display-types.md +6 -0
  34. data/docs/guides/host-application.md +1 -1
  35. data/docs/guides/windows-setup.md +211 -0
  36. data/docs/guides/workflow.md +1 -1
  37. data/docs/reference/asset-pipeline.md +79 -0
  38. data/docs/reference/pages.md +50 -1
  39. data/docs/reference/presenters.md +6 -0
  40. data/docs/reference/workflow.md +2 -2
  41. data/examples/crm/Gemfile +3 -0
  42. data/examples/crm/Gemfile.lock +14 -9
  43. data/examples/crm/app/assets/config/manifest.js +4 -0
  44. data/examples/hr/Gemfile +3 -0
  45. data/examples/hr/Gemfile.lock +14 -9
  46. data/examples/hr/app/assets/config/manifest.js +4 -0
  47. data/examples/showcase/Gemfile +3 -0
  48. data/examples/showcase/Gemfile.lock +12 -9
  49. data/examples/showcase/app/assets/config/manifest.js +4 -0
  50. data/examples/showcase/config/lcp_ruby/pages/main_dashboard.yml +17 -2
  51. data/examples/showcase/config/locales/cs.yml +8 -0
  52. data/examples/showcase/config/locales/en.yml +8 -0
  53. data/examples/todo/Gemfile +3 -0
  54. data/examples/todo/Gemfile.lock +14 -9
  55. data/examples/todo/app/assets/config/manifest.js +4 -0
  56. data/exe/lcp +1 -1
  57. data/lib/generators/lcp_ruby/agent_setup_generator.rb +12 -9
  58. data/lib/generators/lcp_ruby/claude_skills_generator.rb +23 -19
  59. data/lib/generators/lcp_ruby/install_generator.rb +171 -3
  60. data/lib/generators/lcp_ruby/templates/agent_setup/agents_md.md +2 -1
  61. data/lib/generators/lcp_ruby/templates/agent_setup/claude_md.md +3 -2
  62. data/lib/generators/lcp_ruby/templates/install/menu.yml.tt +4 -0
  63. data/lib/generators/lcp_ruby/templates/monitoring/page.yml +5 -0
  64. data/lib/lcp_ruby/app_template.rb +37 -1
  65. data/lib/lcp_ruby/array_query.rb +33 -10
  66. data/lib/lcp_ruby/cli/new_command.rb +9 -4
  67. data/lib/lcp_ruby/cli/run_command.rb +98 -11
  68. data/lib/lcp_ruby/cli/skills_command.rb +27 -15
  69. data/lib/lcp_ruby/cli.rb +5 -1
  70. data/lib/lcp_ruby/configuration.rb +13 -2
  71. data/lib/lcp_ruby/custom_fields/applicator.rb +8 -0
  72. data/lib/lcp_ruby/custom_fields/query.rb +28 -20
  73. data/lib/lcp_ruby/display/markdown_sanitize.rb +36 -0
  74. data/lib/lcp_ruby/display/renderers/markdown.rb +6 -13
  75. data/lib/lcp_ruby/engine.rb +185 -1
  76. data/lib/lcp_ruby/export/data_generator.rb +5 -1
  77. data/lib/lcp_ruby/export/value_formatter.rb +34 -0
  78. data/lib/lcp_ruby/grouped_query/builder.rb +21 -0
  79. data/lib/lcp_ruby/metadata/configuration_validator.rb +5 -5
  80. data/lib/lcp_ruby/metadata/field_path.rb +57 -0
  81. data/lib/lcp_ruby/metadata/loader.rb +33 -1
  82. data/lib/lcp_ruby/metadata/menu_definition.rb +32 -2
  83. data/lib/lcp_ruby/metadata/menu_item.rb +53 -4
  84. data/lib/lcp_ruby/metadata/model_definition.rb +9 -0
  85. data/lib/lcp_ruby/metadata/zone_definition.rb +12 -5
  86. data/lib/lcp_ruby/metrics/json_query.rb +5 -0
  87. data/lib/lcp_ruby/model_factory/builder.rb +5 -0
  88. data/lib/lcp_ruby/model_factory/json_type_applicator.rb +35 -0
  89. data/lib/lcp_ruby/model_factory/label_method_builder.rb +3 -19
  90. data/lib/lcp_ruby/model_factory/schema_manager.rb +19 -0
  91. data/lib/lcp_ruby/pages/definition_validator.rb +13 -0
  92. data/lib/lcp_ruby/presenter/field_value_resolver.rb +36 -19
  93. data/lib/lcp_ruby/schemas/page.json +33 -1
  94. data/lib/lcp_ruby/search/custom_field_filter.rb +5 -0
  95. data/lib/lcp_ruby/skills_installer.rb +15 -2
  96. data/lib/lcp_ruby/tasks/doctor.rb +13 -8
  97. data/lib/lcp_ruby/version.rb +1 -1
  98. data/lib/lcp_ruby/widgets/data_resolver.rb +42 -1
  99. data/lib/lcp_ruby/widgets/date_grouper.rb +19 -0
  100. data/lib/lcp_ruby/workflow/contract_validator.rb +1 -1
  101. data/lib/lcp_ruby/workflow/model_source.rb +2 -2
  102. data/lib/lcp_ruby.rb +18 -0
  103. data/lib/tasks/lcp_ruby_db.rake +8 -1
  104. data/lib/tasks/lcp_ruby_i18n_check.rake +8 -0
  105. metadata +34 -24
  106. data/examples/crm/erd.md +0 -163
  107. data/examples/hr/erd.md +0 -396
  108. data/examples/showcase/erd.md +0 -567
  109. data/examples/todo/erd.md +0 -21
@@ -5,41 +5,45 @@ require "lcp_ruby/skills_installer"
5
5
 
6
6
  module LcpRuby
7
7
  module Generators
8
- # Installs LCP-specific Claude Code skills (`.claude/skills/lcp-*`) into the
9
- # host application by copying them from the gem's bundled `.claude/skills/`.
8
+ # Legacy skills-only in-app entry. Installs LCP-specific AI authoring skills
9
+ # into all supported agent skill directories by copying them from the gem's
10
+ # bundled `.claude/skills/` source.
10
11
  # Re-running refreshes the skills to the version shipped with the currently
11
12
  # installed gem (useful after `bundle update lcp`) and prunes any `lcp-*`
12
13
  # skills the gem no longer ships. Non-`lcp-*` skills are never touched.
13
14
  #
14
- # This is the skills-only in-app entry; `lcp_ruby:agent_setup` composes the
15
- # same `SkillsInstaller` and additionally writes the CLAUDE.md/AGENTS.md
16
- # entrypoint. Both call the installer directly (no generator-calls-generator).
15
+ # This is the skills-only in-app entry; `lcp_ruby:agent_setup` uses the same
16
+ # targets and additionally writes the CLAUDE.md/AGENTS.md entrypoint. Both
17
+ # call the installer directly (no generator-calls-generator).
17
18
  class ClaudeSkillsGenerator < Rails::Generators::Base
18
- desc "Installs LCP Ruby Claude Code skills into .claude/skills/"
19
+ desc "Installs LCP Ruby AI agent skills into .claude/skills/ and .codex/skills/"
19
20
 
20
21
  def install_skills
21
- @result = LcpRuby::SkillsInstaller.new(
22
- target: File.join(destination_root, ".claude", "skills")
23
- ).call
22
+ @results = {}
23
+ LcpRuby::SkillsInstaller.agent_skill_targets(root: destination_root).each do |agent, target|
24
+ result = LcpRuby::SkillsInstaller.new(target: target).call
25
+ @results[agent] = result
24
26
 
25
- @result.created.each { |n| say_status :create, ".claude/skills/#{n}", :green }
26
- @result.updated.each { |n| say_status :update, ".claude/skills/#{n}", :yellow }
27
- @result.removed.each { |n| say_status :remove, ".claude/skills/#{n}", :red }
27
+ relative_target = File.join(LcpRuby::SkillsInstaller::AGENT_DIRECTORIES.fetch(agent), "skills")
28
+ result.created.each { |n| say_status :create, "#{relative_target}/#{n}", :green }
29
+ result.updated.each { |n| say_status :update, "#{relative_target}/#{n}", :yellow }
30
+ result.removed.each { |n| say_status :remove, "#{relative_target}/#{n}", :red }
31
+ end
28
32
  end
29
33
 
30
34
  def show_post_install_message
31
- return if @result.nil? || !@result.any_changes?
35
+ return if @results.nil? || @results.values.none?(&:any_changes?)
32
36
 
33
- installed = (@result.created + @result.updated).sort
37
+ installed = @results.values.flat_map { |result| result.created + result.updated }.uniq.sort
34
38
  say ""
35
- say "Installed #{installed.size} LCP Ruby Claude Code skill(s):", :green
39
+ say "Installed #{installed.size} LCP Ruby AI agent skill(s):", :green
36
40
  installed.each { |n| say " - #{n}" }
37
41
  say ""
38
- say "Skills auto-trigger in Claude Code based on context (description keywords)."
39
- say "List available skills via /skills inside Claude Code."
42
+ say "Skills auto-trigger in Claude Code and Codex based on context (description keywords)."
43
+ say "List available skills via /skills inside Claude Code or the Codex skills list."
40
44
  say ""
41
- say "Refresh after `bundle update lcp`:"
42
- say " bin/rails generate lcp_ruby:claude_skills"
45
+ say "Preferred refresh after `bundle update lcp`:"
46
+ say " bin/rails generate lcp_ruby:agent_setup"
43
47
  say ""
44
48
  end
45
49
  end
@@ -13,11 +13,45 @@ module LcpRuby
13
13
  desc "Sets up LCP Ruby in an existing Rails application"
14
14
 
15
15
  class_option :skip_sample, type: :boolean, default: false,
16
- desc: "Skip creating sample item model"
16
+ desc: "Skip the sample `item` model + presenter + permissions + view group. Default: create them — a Hello-World scaffold so a fresh install has something to click at /items. Each generated file carries a DELETE ME header pointing at all four siblings."
17
+
18
+ # Header injected into each sample file (DSL + YAML both use `#` for
19
+ # comments, so one literal works for both formats). DslToYaml strips
20
+ # in-source comments during conversion, so we prepend after copy via
21
+ # `prepend_sample_header!` rather than embedding in the template.
22
+ SAMPLE_HEADER = <<~HEADER
23
+ # DELETE ME — sample scaffold for first-day discovery.
24
+ # `lcp_ruby:install` (and `lcp new` without `--skip-sample`) seeds
25
+ # this `item` entity so a fresh install has something to click at
26
+ # /items. Once you've added your own entities via
27
+ # `rails g lcp_ruby:entity`, delete all four sample files:
28
+ # config/lcp_ruby/models/item.{rb,yml}
29
+ # config/lcp_ruby/presenters/items.{rb,yml}
30
+ # config/lcp_ruby/permissions/item.yml
31
+ # config/lcp_ruby/views/items.{rb,yml}
32
+ # ...and remove `- view_group: items` from config/lcp_ruby/menu.yml.
33
+
34
+ HEADER
17
35
 
18
36
  class_option :active_storage, type: :boolean, default: true,
19
37
  desc: "Install Active Storage migrations (idempotent — skipped if already present)"
20
38
 
39
+ class_option :skip_asset_pipeline, type: :boolean, default: false,
40
+ desc: "Skip the Sprockets-compatibility shim (don't add `sprockets-rails` or create app/assets/config/manifest.js). " \
41
+ "Default: install. Only opt out if you've wired your own ESM bundler — LCP's JS won't load without one or the other."
42
+
43
+ # Sprockets manifest entries required for LCP's JS + CSS to be
44
+ # served by `javascript_include_tag` / `stylesheet_link_tag`. Used by
45
+ # `install_asset_pipeline_compat` (this generator) and mirrored in
46
+ # the engine's `check_asset_pipeline_compat!` error message — keep
47
+ # the two in sync.
48
+ SPROCKETS_MANIFEST_LINKS = [
49
+ "//= link lcp_ruby/application.js",
50
+ "//= link lcp_ruby/application.css",
51
+ "//= link lcp_ruby/tom-select.css",
52
+ "//= link lcp_ruby/tom-select.complete.min.js"
53
+ ].freeze
54
+
21
55
  class_option :menu_layout, type: :string, default: "both", enum: %w[top sidebar both],
22
56
  desc: "Menu layout sections to seed (top, sidebar, or both — default: both). " \
23
57
  "Seeds `# lcp:menu <section>` insertion markers for `rails g lcp_ruby:entity --menu=...`."
@@ -63,6 +97,24 @@ module LcpRuby
63
97
  route 'mount LcpRuby::Engine => "/"'
64
98
  end
65
99
 
100
+ # Sprockets compatibility shim (audit #16). Rails 8 ships Propshaft
101
+ # by default, which doesn't understand the `//= require` directives
102
+ # LCP's JS bundle uses. Without this shim, the layout's `<script>`
103
+ # tags either don't render (the old `respond_to?(:assets)` gate) or
104
+ # render but 404 — both present as "top nav invisible, no JS errors,
105
+ # no console output." The engine's boot check (`check_asset_pipeline_compat!`)
106
+ # raises if Propshaft is loaded without sprockets-rails; this method
107
+ # is the one-shot fix for that error.
108
+ #
109
+ # Idempotent: skips Gemfile insertion if the gem line already exists;
110
+ # appends only missing link directives to an existing manifest.js.
111
+ def install_asset_pipeline_compat
112
+ return if options[:skip_asset_pipeline]
113
+
114
+ add_sprockets_rails_to_gemfile!
115
+ ensure_sprockets_manifest!
116
+ end
117
+
66
118
  def create_directory_structure
67
119
  %w[models presenters permissions views].each do |dir|
68
120
  empty_directory "config/lcp_ruby/#{dir}"
@@ -75,6 +127,7 @@ module LcpRuby
75
127
  copy_dsl_or_yaml "model.rb",
76
128
  dsl_target: "config/lcp_ruby/models/item.rb",
77
129
  yaml_target: "config/lcp_ruby/models/item.yml"
130
+ prepend_sample_header!(yaml_format? ? "config/lcp_ruby/models/item.yml" : "config/lcp_ruby/models/item.rb")
78
131
  end
79
132
 
80
133
  def create_sample_presenter
@@ -83,12 +136,14 @@ module LcpRuby
83
136
  copy_dsl_or_yaml "presenter.rb",
84
137
  dsl_target: "config/lcp_ruby/presenters/items.rb",
85
138
  yaml_target: "config/lcp_ruby/presenters/items.yml"
139
+ prepend_sample_header!(yaml_format? ? "config/lcp_ruby/presenters/items.yml" : "config/lcp_ruby/presenters/items.rb")
86
140
  end
87
141
 
88
142
  def create_sample_permissions
89
143
  return if options[:skip_sample]
90
144
 
91
145
  template "permissions.yml", "config/lcp_ruby/permissions/item.yml"
146
+ prepend_sample_header!("config/lcp_ruby/permissions/item.yml")
92
147
  end
93
148
 
94
149
  def create_sample_view_group
@@ -97,6 +152,7 @@ module LcpRuby
97
152
  copy_dsl_or_yaml "view_group.rb",
98
153
  dsl_target: "config/lcp_ruby/views/items.rb",
99
154
  yaml_target: "config/lcp_ruby/views/items.yml"
155
+ prepend_sample_header!(yaml_format? ? "config/lcp_ruby/views/items.yml" : "config/lcp_ruby/views/items.rb")
100
156
  end
101
157
 
102
158
  def create_default_permissions
@@ -210,6 +266,58 @@ module LcpRuby
210
266
  RUBY
211
267
  end
212
268
 
269
+ # When the Sprockets compatibility shim is skipped, the engine's boot
270
+ # check would otherwise raise LcpRuby::AssetPipelineError on the next
271
+ # non-generator boot. Write the matching opt-out so the documented
272
+ # `--skip-asset-pipeline` flag yields a bootable app.
273
+ #
274
+ # MUST stay a separate step from `create_initializer` (which early-returns
275
+ # when the initializer already exists): on a re-run or install over a
276
+ # pre-existing initializer, the opt-out has to be applied regardless of
277
+ # whether the file was freshly created — otherwise the flag silently
278
+ # no-ops and the app won't boot. Idempotent: skips when the line is
279
+ # already present.
280
+ def configure_skip_asset_pipeline
281
+ return unless options[:skip_asset_pipeline]
282
+
283
+ initializer_path = "config/initializers/lcp_ruby.rb"
284
+ full_path = File.join(destination_root, initializer_path)
285
+ unless File.exist?(full_path)
286
+ say_status :skip, "skip_asset_pipeline_check (no #{initializer_path})", :yellow
287
+ return
288
+ end
289
+
290
+ content = File.read(full_path)
291
+ return if content.include?("skip_asset_pipeline_check")
292
+
293
+ # Match the configure block (both `do |x|` and brace `{ |x|` forms, the
294
+ # latter is what the engine's AssetPipelineError message suggests) and
295
+ # CAPTURE its block-variable name so the injected line targets the host's
296
+ # actual var (`config`, `c`, …) — a hardcoded `config.` would NameError
297
+ # at boot on a `|c|` initializer. Not anchored to a trailing newline, so
298
+ # a `do |config| # comment` line still matches.
299
+ match = content.match(/LcpRuby\.configure\s*(?:do|\{)\s*\|\s*(\w+)\s*\|/)
300
+ unless match
301
+ say_status :warn,
302
+ "could not anchor skip_asset_pipeline_check in #{initializer_path}; " \
303
+ "add `config.skip_asset_pipeline_check = true` inside the LcpRuby.configure block manually",
304
+ :yellow
305
+ return
306
+ end
307
+
308
+ var = match[1]
309
+ optout = <<~RUBY.indent(2)
310
+ # --skip-asset-pipeline was passed: the Sprockets compatibility shim
311
+ # is NOT installed, so the boot-time asset-pipeline check is opted
312
+ # out too (otherwise the engine raises LcpRuby::AssetPipelineError).
313
+ # Only correct if you've wired your own JS bundler (esbuild,
314
+ # importmap with a custom pin map, jsbundling, etc.).
315
+ #{var}.skip_asset_pipeline_check = true
316
+ RUBY
317
+
318
+ inject_into_file initializer_path, "\n#{optout}", after: match[0]
319
+ end
320
+
213
321
  def add_current_user_helper
214
322
  controller_path = "app/controllers/application_controller.rb"
215
323
  full_path = File.join(destination_root, controller_path)
@@ -260,6 +368,62 @@ module LcpRuby
260
368
  def invoke_active_storage_install!
261
369
  rails_command "active_storage:install", abort_on_failure: false
262
370
  end
371
+
372
+ # Thor's `prepend_to_file` is idempotent on string match but we
373
+ # also want to be no-op on rerun where the file was overwritten
374
+ # by `template`. Cheapest guard: skip when the header is already
375
+ # present at the top of the file.
376
+ def prepend_sample_header!(relative_path)
377
+ full_path = File.join(destination_root, relative_path)
378
+ return unless File.exist?(full_path)
379
+ return if File.read(full_path).start_with?("# DELETE ME")
380
+ prepend_to_file relative_path, SAMPLE_HEADER
381
+ end
382
+
383
+ # Idempotent Gemfile insertion. `gem "sprockets-rails"` matches
384
+ # any quote style or version constraint so re-runs don't duplicate.
385
+ def add_sprockets_rails_to_gemfile!
386
+ gemfile_path = File.join(destination_root, "Gemfile")
387
+ unless File.exist?(gemfile_path)
388
+ say_status :skip, "sprockets-rails (no Gemfile — manual install required)", :yellow
389
+ return
390
+ end
391
+
392
+ if File.read(gemfile_path).match?(/^\s*gem\s+["']sprockets-rails["']/)
393
+ say_status :identical, "Gemfile already has sprockets-rails"
394
+ return
395
+ end
396
+
397
+ gem "sprockets-rails"
398
+ end
399
+
400
+ # Creates or updates `app/assets/config/manifest.js`. If the file
401
+ # exists, only the missing `//= link` directives are appended —
402
+ # idempotent across re-runs and friendly to hosts that already
403
+ # have host-specific entries (e.g. `Chart.bundle.js` for charts).
404
+ def ensure_sprockets_manifest!
405
+ manifest_path = "app/assets/config/manifest.js"
406
+ full_path = File.join(destination_root, manifest_path)
407
+
408
+ if File.exist?(full_path)
409
+ existing = File.read(full_path)
410
+ missing = SPROCKETS_MANIFEST_LINKS.reject { |line| existing.include?(line) }
411
+ if missing.empty?
412
+ say_status :identical, manifest_path
413
+ else
414
+ append_to_file manifest_path, "\n# Added by lcp_ruby:install\n#{missing.join("\n")}\n"
415
+ end
416
+ else
417
+ create_file manifest_path, <<~MANIFEST
418
+ # LCP Ruby asset manifest (audit #16).
419
+ # `//= link` directives tell Sprockets to precompile + serve
420
+ # the listed files. The engine's JS bundle (~50 files) and
421
+ # CSS bundle are the load-bearing entries — without them
422
+ # the top nav, row-click, and form widgets are inert.
423
+ #{SPROCKETS_MANIFEST_LINKS.join("\n")}
424
+ MANIFEST
425
+ end
426
+ end
263
427
  end
264
428
 
265
429
  # Agent affordances: copies the lcp-* skills and writes CLAUDE.md/AGENTS.md
@@ -277,7 +441,7 @@ module LcpRuby
277
441
  say "LCP Ruby installed!", :green
278
442
  say ""
279
443
  unless options[:skip_sample]
280
- say "Sample files:"
444
+ say "Sample files (each carries a 'DELETE ME' header — remove when you add your own entities):"
281
445
  say " config/lcp_ruby/models/item.#{format_extension}"
282
446
  say " config/lcp_ruby/presenters/items.#{format_extension}"
283
447
  say " config/lcp_ruby/permissions/item.yml"
@@ -292,7 +456,11 @@ module LcpRuby
292
456
  say "Next steps:"
293
457
  say " 1. rails db:prepare"
294
458
  say " 2. rails s"
295
- say " 3. Visit http://localhost:3000/items"
459
+ if options[:skip_sample]
460
+ say " 3. Add your first entity: rails generate lcp_ruby:entity Foo name:string"
461
+ else
462
+ say " 3. Visit http://localhost:3000/items"
463
+ end
296
464
  say ""
297
465
  say "Add features with generators:"
298
466
  say " rails generate lcp_ruby:install_auth # Devise authentication"
@@ -1,3 +1,4 @@
1
1
  This project uses **LCP Ruby**. See `CLAUDE.md` for orientation (project
2
2
  layout, skills, docs, validation) — the same guidance applies to all agents.
3
- For authoring patterns, also read `.claude/skills/lcp-*/SKILL.md`.
3
+ For authoring patterns, use the installed skills in `.codex/skills/lcp-*` or
4
+ `.claude/skills/lcp-*`.
@@ -2,8 +2,9 @@ This is an **LCP Ruby** project (low-code platform). App behavior is metadata
2
2
  under `config/lcp_ruby/`: `models/` (data), `presenters/` (UI),
3
3
  `permissions/` (roles).
4
4
 
5
- - **Authoring guidance:** skills in `.claude/skills/lcp-*` (auto-trigger in
6
- Claude Code; other agents can read the `SKILL.md` files directly).
5
+ - **Authoring guidance:** skills in `.claude/skills/lcp-*` for Claude Code and
6
+ `.codex/skills/lcp-*` for Codex. Both directories contain the same `SKILL.md`
7
+ guidance and auto-trigger in their respective agent.
7
8
  - **Reference docs:** in-app `bundle exec rake lcp_ruby:docs` (matches the
8
9
  bundled gem version), or `lcp docs path` globally — start at `docs/README.md`.
9
10
  When a skill or doc says `docs/X`, resolve it under that path.
@@ -6,7 +6,9 @@ menu:
6
6
  <% if %w[top both].include?(options[:menu_layout]) -%>
7
7
  # >>> LCP_USER_MENU_BLOCK (rewritten by lcp_ruby:install_auth) <<<
8
8
  top_menu:
9
+ <% unless options[:skip_sample] -%>
9
10
  - view_group: items
11
+ <% end -%>
10
12
  # lcp:menu top # entity generator marker - do not edit/delete
11
13
 
12
14
  # Uncomment when adding authentication, or run
@@ -62,7 +64,9 @@ menu:
62
64
  <% end -%>
63
65
  <% if options[:menu_layout] == "sidebar" -%>
64
66
  sidebar_menu:
67
+ <% unless options[:skip_sample] -%>
65
68
  - view_group: items
69
+ <% end -%>
66
70
  # lcp:menu sidebar # entity generator marker - do not edit/delete
67
71
  <% elsif options[:menu_layout] == "both" -%>
68
72
 
@@ -2,6 +2,11 @@ page:
2
2
  name: monitoring_dashboard
3
3
  slug: monitoring
4
4
  layout: grid
5
+ # Operator-facing dashboard (error counts, metrics, recent errors).
6
+ # Restricted to admin by default; relax this when other roles need read access.
7
+ visible_when:
8
+ service: current_user_role
9
+ params: { in: [admin] }
5
10
 
6
11
  zones:
7
12
  - name: models_loaded
@@ -11,6 +11,8 @@
11
11
  # LCP_TIMEZONE - default timezone (default: "UTC")
12
12
  # LCP_LOCALE - default locale (default: "en")
13
13
  # LCP_MENU_LAYOUT - "top", "sidebar", or "both" (default: "both")
14
+ # LCP_SKIP_SAMPLE - "1" to skip the Hello-World item model, "0" (default) to scaffold it
15
+ # LCP_SKIP_ASSET_PIPELINE - "1" to skip Sprockets compat shim (only for ESM-bundler users), "0" (default) to install it
14
16
 
15
17
  require "json"
16
18
 
@@ -22,6 +24,8 @@ gem_source = ENV.fetch("LCP_GEM_SOURCE", "rubygems")
22
24
  timezone = ENV.fetch("LCP_TIMEZONE", "UTC")
23
25
  locale = ENV.fetch("LCP_LOCALE", "en")
24
26
  menu_layout = ENV.fetch("LCP_MENU_LAYOUT", "both")
27
+ skip_sample = ENV.fetch("LCP_SKIP_SAMPLE", "0") == "1"
28
+ skip_asset_pipeline = ENV.fetch("LCP_SKIP_ASSET_PIPELINE", "0") == "1"
25
29
 
26
30
  # Add lcp gem
27
31
  case gem_source
@@ -35,6 +39,34 @@ else
35
39
  gem "lcp"
36
40
  end
37
41
 
42
+ # Sprockets compatibility shim (audit #16). Rails 8 ships Propshaft by
43
+ # default; LCP's JS uses Sprockets `//= require` directives that Propshaft
44
+ # can't parse, so without sprockets-rails the engine's nav + Stimulus
45
+ # controllers are silently inert. Add the gem at template-level so it's
46
+ # bundled before `after_bundle`'s `rails generate lcp_ruby:install` boots
47
+ # Rails (the install generator's `install_asset_pipeline_compat` step
48
+ # would otherwise hit the engine's loud boot check before getting to add
49
+ # the gem). The install generator's logic is still the source of truth —
50
+ # this hoist just sidesteps the chicken-and-egg.
51
+ gem "sprockets-rails" unless skip_asset_pipeline
52
+
53
+ # Create app/assets/config/manifest.js at template level too. sprockets-rails
54
+ # 3.5+ RAISES (ManifestNeededError) the moment its railtie loads without a
55
+ # manifest, and Rails 8's `rails new` boots the app (solid_cache:install, etc.)
56
+ # in `after_bundle` BEFORE `lcp_ruby:install` would create it — so the manifest
57
+ # must exist before any boot. `lcp_ruby:install`'s `ensure_sprockets_manifest!`
58
+ # is idempotent (only appends missing `//= link` lines), so this is the
59
+ # source-of-truth's early twin, not a fork.
60
+ unless skip_asset_pipeline
61
+ create_file "app/assets/config/manifest.js", <<~MANIFEST
62
+ //= link_directory ../stylesheets .css
63
+ //= link lcp_ruby/application.js
64
+ //= link lcp_ruby/application.css
65
+ //= link lcp_ruby/tom-select.css
66
+ //= link lcp_ruby/tom-select.complete.min.js
67
+ MANIFEST
68
+ end
69
+
38
70
  # Generators that accept --format option. Source of truth lives in
39
71
  # `LcpRuby::Generators::FeatureRegistry` once the gem is loaded
40
72
  # (`after_bundle`); this list mirrors the wizard's curated set.
@@ -89,10 +121,14 @@ after_bundle do
89
121
  # abort_on_failure behaviour (no `inline:`).
90
122
  # Core install also sets up agent affordances at the end (skills +
91
123
  # CLAUDE.md/AGENTS.md via `lcp_ruby:agent_setup`) — passive, no runtime impact.
92
- generate "lcp_ruby:install",
124
+ install_args = [
93
125
  "--format=#{format}",
94
126
  "--menu-layout=#{menu_layout}",
95
127
  "--locale=#{locale}"
128
+ ]
129
+ install_args << "--skip-sample" if skip_sample
130
+ install_args << "--skip-asset-pipeline" if skip_asset_pipeline
131
+ generate "lcp_ruby:install", *install_args
96
132
 
97
133
  # Build manifest header before the feature loop so even an empty preset
98
134
  # produces a manifest the doctor can introspect.
@@ -1,7 +1,9 @@
1
1
  module LcpRuby
2
2
  # DB-portable query helpers for array fields.
3
- # Uses native PG array operators (@>, &&, <@) on PostgreSQL
4
- # and json_each() subqueries on SQLite.
3
+ # Uses native PG array operators (@>, &&, <@) on PostgreSQL, a json_each()
4
+ # subquery on SQLite, and a JSON_TABLE() row source on MySQL/MariaDB (which
5
+ # has no json_each). Both non-PG backends expose each element as `je.value`,
6
+ # so the conditions below are shared.
5
7
  class ArrayQuery
6
8
  class << self
7
9
  # Records where the array field contains ALL of the given values.
@@ -15,7 +17,7 @@ module LcpRuby
15
17
  condition = if LcpRuby.postgresql?
16
18
  "#{col} @> #{pg_array_literal(values, c, item_type)}"
17
19
  else
18
- "(SELECT COUNT(DISTINCT je.value) FROM json_each(#{col}) je " \
20
+ "(SELECT COUNT(DISTINCT je.value) FROM #{json_elements(col)} " \
19
21
  "WHERE je.value IN (#{quoted_values(values, c)})) = #{values.size}"
20
22
  end
21
23
 
@@ -33,7 +35,7 @@ module LcpRuby
33
35
  condition = if LcpRuby.postgresql?
34
36
  "#{col} && #{pg_array_literal(values, c, item_type)}"
35
37
  else
36
- "EXISTS (SELECT 1 FROM json_each(#{col}) je " \
38
+ "EXISTS (SELECT 1 FROM #{json_elements(col)} " \
37
39
  "WHERE je.value IN (#{quoted_values(values, c)}))"
38
40
  end
39
41
 
@@ -50,16 +52,14 @@ module LcpRuby
50
52
 
51
53
  if values.empty?
52
54
  return scope.where(Arel.sql(
53
- LcpRuby.postgresql? ?
54
- "#{col} = '{}'" :
55
- "json_array_length(#{col}) = 0"
55
+ LcpRuby.postgresql? ? "#{col} = '{}'" : "#{json_length(col)} = 0"
56
56
  ))
57
57
  end
58
58
 
59
59
  condition = if LcpRuby.postgresql?
60
60
  "#{col} <@ #{pg_array_literal(values, c, item_type)}"
61
61
  else
62
- "NOT EXISTS (SELECT 1 FROM json_each(#{col}) je " \
62
+ "NOT EXISTS (SELECT 1 FROM #{json_elements(col)} " \
63
63
  "WHERE je.value NOT IN (#{quoted_values(values, c)}))"
64
64
  end
65
65
 
@@ -74,7 +74,7 @@ module LcpRuby
74
74
  if LcpRuby.postgresql?
75
75
  "COALESCE(array_length(#{col}, 1), 0)"
76
76
  else
77
- "json_array_length(#{col})"
77
+ json_length(col)
78
78
  end
79
79
  end
80
80
 
@@ -88,13 +88,36 @@ module LcpRuby
88
88
  "EXISTS (SELECT 1 FROM unnest(#{col}) item " \
89
89
  "WHERE item ILIKE #{quoted_query})"
90
90
  else
91
- "EXISTS (SELECT 1 FROM json_each(#{col}) je " \
91
+ "EXISTS (SELECT 1 FROM #{json_elements(col)} " \
92
92
  "WHERE je.value LIKE #{quoted_query})"
93
93
  end
94
94
  end
95
95
 
96
96
  private
97
97
 
98
+ # Row source aliased `je` exposing each array element as a TEXT `je.value`,
99
+ # so the conditions can compare it against the quoted string literals from
100
+ # quoted_values regardless of the element's JSON type.
101
+ #
102
+ # MySQL/MariaDB has no json_each — JSON_TABLE with a LONGTEXT column yields
103
+ # text already (LONGTEXT, not a sized VARCHAR, so elements > 255 chars such
104
+ # as URLs aren't silently truncated). SQLite's json_each preserves the JSON
105
+ # type, so a numeric element comes back as an INTEGER/REAL that never equals
106
+ # the string literal `'1'` — wrap it in a derived table that CASTs to TEXT
107
+ # (TEXT is not a valid MySQL CAST target, hence the per-adapter split).
108
+ def json_elements(col)
109
+ if LcpRuby.mysql?
110
+ "JSON_TABLE(#{col}, '$[*]' COLUMNS(value LONGTEXT PATH '$')) je"
111
+ else
112
+ "(SELECT CAST(value AS TEXT) AS value FROM json_each(#{col})) je"
113
+ end
114
+ end
115
+
116
+ # Number of elements in the JSON array column.
117
+ def json_length(col)
118
+ LcpRuby.mysql? ? "JSON_LENGTH(#{col})" : "json_array_length(#{col})"
119
+ end
120
+
98
121
  def connection
99
122
  ActiveRecord::Base.connection
100
123
  end
@@ -9,7 +9,7 @@ module LcpRuby
9
9
  module CLI
10
10
  class NewCommand
11
11
  MINIMUM_RAILS_VERSION = "8.1"
12
- MINIMUM_RUBY_VERSION = "3.1"
12
+ MINIMUM_RUBY_VERSION = "3.2"
13
13
 
14
14
  DATABASES = %w[sqlite3 postgresql mysql2].freeze
15
15
 
@@ -134,7 +134,10 @@ module LcpRuby
134
134
  end
135
135
 
136
136
  def check_rails_version!
137
- version_output = `rails --version 2>/dev/null`.strip
137
+ # File::NULL (not a literal /dev/null) so the stderr redirect is valid
138
+ # on Windows cmd.exe too — otherwise the shell-out fails and this reads
139
+ # as "rails not found", aborting `lcp new` on Windows.
140
+ version_output = `rails --version 2>#{File::NULL}`.strip
138
141
  if version_output.empty?
139
142
  abort "Error: 'rails' command not found. Install Rails #{MINIMUM_RAILS_VERSION}+:\n gem install rails"
140
143
  end
@@ -387,7 +390,9 @@ module LcpRuby
387
390
  "LCP_GEM_SOURCE" => @gem_source,
388
391
  "LCP_TIMEZONE" => @timezone,
389
392
  "LCP_LOCALE" => @locale,
390
- "LCP_MENU_LAYOUT" => @menu_layout
393
+ "LCP_MENU_LAYOUT" => @menu_layout,
394
+ "LCP_SKIP_SAMPLE" => @options[:skip_sample] ? "1" : "0",
395
+ "LCP_SKIP_ASSET_PIPELINE" => @options[:skip_asset_pipeline] ? "1" : "0"
391
396
  }
392
397
 
393
398
  # Action Text is installed by default — `:rich_text` fields are a
@@ -436,7 +441,7 @@ module LcpRuby
436
441
  say ""
437
442
  say "Application created successfully!", :green
438
443
  say ""
439
- say "AI agents are set up: CLAUDE.md + .claude/skills/lcp-* are ready.", :green
444
+ say "AI agents are set up: CLAUDE.md + .claude/skills and .codex/skills are ready.", :green
440
445
  say " Reference docs: bundle exec rake lcp_ruby:docs"
441
446
  say " Validate config: bundle exec rake lcp_ruby:validate"
442
447
  say ""