lazyopencode 0.1.1__tar.gz → 0.2.0__tar.gz

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 (126) hide show
  1. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/PKG-INFO +2 -1
  2. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/pyproject.toml +2 -0
  3. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/__init__.py +12 -1
  4. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/_version.py +2 -2
  5. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/app.py +132 -2
  6. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/bindings.py +2 -0
  7. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/mixins/help.py +2 -0
  8. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/models/customization.py +35 -4
  9. lazyopencode-0.2.0/src/lazyopencode/services/claude_code/__init__.py +9 -0
  10. lazyopencode-0.2.0/src/lazyopencode/services/claude_code/discovery.py +158 -0
  11. lazyopencode-0.2.0/src/lazyopencode/services/claude_code/parsers/__init__.py +7 -0
  12. lazyopencode-0.2.0/src/lazyopencode/services/claude_code/parsers/agent.py +58 -0
  13. lazyopencode-0.2.0/src/lazyopencode/services/claude_code/parsers/command.py +75 -0
  14. lazyopencode-0.2.0/src/lazyopencode/services/claude_code/parsers/skill.py +130 -0
  15. lazyopencode-0.2.0/src/lazyopencode/services/claude_code/plugin_loader.py +164 -0
  16. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/services/discovery.py +25 -4
  17. lazyopencode-0.2.0/src/lazyopencode/services/writer.py +119 -0
  18. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/widgets/app_footer.py +1 -1
  19. lazyopencode-0.2.0/src/lazyopencode/widgets/level_selector.py +130 -0
  20. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/uv.lock +22 -0
  21. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/.github/release-drafter.yml +0 -0
  22. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/.github/workflows/ci.yml +0 -0
  23. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/.github/workflows/publish.yml +0 -0
  24. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/.github/workflows/release-drafter.yml +0 -0
  25. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/.gitignore +0 -0
  26. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/.opencode/command/run-quality-gates.md +0 -0
  27. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/.opencode/skill/quality-gates/SKILL.md +0 -0
  28. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/.pre-commit-config.yaml +0 -0
  29. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/AGENTS.md +0 -0
  30. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/LICENSE +0 -0
  31. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/README.md +0 -0
  32. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/artifacts/demo.png +0 -0
  33. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/artifacts/lazyclaude-reference.png +0 -0
  34. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/opencode.json +0 -0
  35. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/scripts/check_quality.sh +0 -0
  36. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/__main__.py +0 -0
  37. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/mixins/filtering.py +0 -0
  38. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/mixins/navigation.py +0 -0
  39. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/models/__init__.py +0 -0
  40. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/services/__init__.py +0 -0
  41. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/services/gitignore_filter.py +0 -0
  42. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/services/parsers/__init__.py +0 -0
  43. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/services/parsers/agent.py +0 -0
  44. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/services/parsers/command.py +0 -0
  45. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/services/parsers/mcp.py +0 -0
  46. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/services/parsers/plugin.py +0 -0
  47. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/services/parsers/rules.py +0 -0
  48. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/services/parsers/skill.py +0 -0
  49. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/services/parsers/tool.py +0 -0
  50. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/styles/app.tcss +0 -0
  51. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/themes.py +0 -0
  52. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/widgets/__init__.py +0 -0
  53. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/widgets/combined_panel.py +0 -0
  54. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/widgets/detail_pane.py +0 -0
  55. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/widgets/filter_input.py +0 -0
  56. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/widgets/helpers/__init__.py +0 -0
  57. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/widgets/helpers/rendering.py +0 -0
  58. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/widgets/status_panel.py +0 -0
  59. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/src/lazyopencode/widgets/type_panel.py +0 -0
  60. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/AGENTS.md +0 -0
  61. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/conftest.py +0 -0
  62. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/__init__.py +0 -0
  63. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/discovery/__init__.py +0 -0
  64. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/discovery/test_agents.py +0 -0
  65. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/discovery/test_commands.py +0 -0
  66. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/discovery/test_full_discovery.py +0 -0
  67. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/discovery/test_mcps.py +0 -0
  68. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/discovery/test_rules.py +0 -0
  69. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/discovery/test_skills.py +0 -0
  70. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/fixtures/agent/explorer.md +0 -0
  71. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/fixtures/command/greet.md +0 -0
  72. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/fixtures/mcp/project-opencode.json +0 -0
  73. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/fixtures/mcp/user-opencode.json +0 -0
  74. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/fixtures/memory/AGENTS.md +0 -0
  75. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/fixtures/project/AGENTS.md +0 -0
  76. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/fixtures/project/agent/reviewer.md +0 -0
  77. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/fixtures/project/command/project-cmd.md +0 -0
  78. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/fixtures/project/docs/guidelines.md +0 -0
  79. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/fixtures/project/skill/project-skill/SKILL.md +0 -0
  80. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/fixtures/project/skill/project-skill/src/helper.py +0 -0
  81. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/fixtures/skill/task-tracker/SKILL.md +0 -0
  82. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/fixtures/skill/task-tracker/reference.md +0 -0
  83. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/integration/fixtures/skill/task-tracker/scripts/run.sh +0 -0
  84. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/SPEC.md +0 -0
  85. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/__init__.py +0 -0
  86. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/conftest.py +0 -0
  87. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/complex/.opencode/agent/reviewer.md +0 -0
  88. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/complex/.opencode/command/verify.md +0 -0
  89. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/complex/.opencode/plugin/metrics.ts +0 -0
  90. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/complex/.opencode/skill/deploy-helper/SKILL.md +0 -0
  91. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/complex/.opencode/skill/deploy-helper/scripts/deploy.sh +0 -0
  92. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/complex/.opencode/tool/search.ts +0 -0
  93. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/complex/AGENTS.md +0 -0
  94. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/complex/README.md +0 -0
  95. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/complex/docs/guidelines.md +0 -0
  96. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/complex/opencode.json +0 -0
  97. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/complex/prompts/auditor.txt +0 -0
  98. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/file_commands/.opencode/command/deploy.md +0 -0
  99. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/file_commands/.opencode/command/greet.md +0 -0
  100. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/file_commands/README.md +0 -0
  101. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/file_references/README.md +0 -0
  102. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/file_references/opencode.json +0 -0
  103. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/file_references/prompts/agent.txt +0 -0
  104. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/file_references/templates/cmd.txt +0 -0
  105. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/inline_commands/README.md +0 -0
  106. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/inline_commands/opencode.json +0 -0
  107. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/minimal/AGENTS.md +0 -0
  108. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/minimal/README.md +0 -0
  109. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/mixed_config/.opencode/agent/file-agent.md +0 -0
  110. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/mixed_config/.opencode/command/file-cmd.md +0 -0
  111. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/mixed_config/AGENTS.md +0 -0
  112. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/mixed_config/README.md +0 -0
  113. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/mixed_config/opencode.json +0 -0
  114. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/rich_opencode_json/README.md +0 -0
  115. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/rich_opencode_json/docs/api-standards.md +0 -0
  116. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/rich_opencode_json/opencode.json +0 -0
  117. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/rich_opencode_json/prompts/security-audit.txt +0 -0
  118. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/rich_opencode_json/scripts/fake-lint.sh +0 -0
  119. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/skills_with_tree/.opencode/skill/my-skill/SKILL.md +0 -0
  120. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/skills_with_tree/.opencode/skill/my-skill/docs/guide.md +0 -0
  121. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/skills_with_tree/.opencode/skill/my-skill/scripts/run.sh +0 -0
  122. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/scenarios/skills_with_tree/README.md +0 -0
  123. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/spec/test_scenarios.py +0 -0
  124. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/test_inline.py +0 -0
  125. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/test_version.py +0 -0
  126. {lazyopencode-0.1.1 → lazyopencode-0.2.0}/tests/unit/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lazyopencode
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: A lazygit-style TUI for visualizing OpenCode customizations
5
5
  Project-URL: Homepage, https://github.com/nikiforovall/lazyopencode
6
6
  Project-URL: Repository, https://github.com/nikiforovall/lazyopencode
@@ -19,6 +19,7 @@ Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Topic :: Software Development :: User Interfaces
20
20
  Requires-Python: >=3.11
21
21
  Requires-Dist: pathspec>=0.12.0
22
+ Requires-Dist: pyperclip>=1.9.0
22
23
  Requires-Dist: pyyaml>=6.0
23
24
  Requires-Dist: rich>=13.0.0
24
25
  Requires-Dist: textual>=0.89.0
@@ -26,6 +26,7 @@ dependencies = [
26
26
  "rich>=13.0.0",
27
27
  "pyyaml>=6.0",
28
28
  "pathspec>=0.12.0",
29
+ "pyperclip>=1.9.0",
29
30
  ]
30
31
 
31
32
  [project.urls]
@@ -108,5 +109,6 @@ dev = [
108
109
  "pytest>=9.0.1",
109
110
  "pytest-asyncio>=1.3.0",
110
111
  "ruff>=0.14.8",
112
+ "types-pyperclip>=1.9.0",
111
113
  "types-pyyaml>=6.0.12.20250915",
112
114
  ]
@@ -38,11 +38,22 @@ def main() -> None:
38
38
  help="Override user config path (default: ~/.config/opencode)",
39
39
  )
40
40
 
41
+ parser.add_argument(
42
+ "--claude-code",
43
+ action="store_true",
44
+ default=False,
45
+ help="Enable Claude Code customizations discovery (from ~/.claude/)",
46
+ )
47
+
41
48
  args = parser.parse_args()
42
49
 
43
50
  # Handle directory argument - resolve to absolute path
44
51
  project_root = args.directory.resolve() if args.directory else None
45
52
  user_config = args.user_config.resolve() if args.user_config else None
46
53
 
47
- app = create_app(project_root=project_root, global_config_path=user_config)
54
+ app = create_app(
55
+ project_root=project_root,
56
+ global_config_path=user_config,
57
+ enable_claude_code=args.claude_code,
58
+ )
48
59
  app.run()
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.1.1'
32
- __version_tuple__ = version_tuple = (0, 1, 1)
31
+ __version__ = version = '0.2.0'
32
+ __version_tuple__ = version_tuple = (0, 2, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -16,6 +16,7 @@ from lazyopencode.mixins.help import HelpMixin
16
16
  from lazyopencode.mixins.navigation import NavigationMixin
17
17
  from lazyopencode.models.customization import (
18
18
  ConfigLevel,
19
+ ConfigSource,
19
20
  Customization,
20
21
  CustomizationType,
21
22
  )
@@ -25,6 +26,7 @@ from lazyopencode.widgets.app_footer import AppFooter
25
26
  from lazyopencode.widgets.combined_panel import CombinedPanel
26
27
  from lazyopencode.widgets.detail_pane import MainPane
27
28
  from lazyopencode.widgets.filter_input import FilterInput
29
+ from lazyopencode.widgets.level_selector import LevelSelector
28
30
  from lazyopencode.widgets.status_panel import StatusPanel
29
31
  from lazyopencode.widgets.type_panel import TypePanel
30
32
 
@@ -44,6 +46,7 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
44
46
  discovery_service: ConfigDiscoveryService | None = None,
45
47
  project_root: Path | None = None,
46
48
  global_config_path: Path | None = None,
49
+ enable_claude_code: bool = False,
47
50
  ) -> None:
48
51
  """Initialize LazyOpenCode application."""
49
52
  super().__init__()
@@ -51,6 +54,7 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
51
54
  self._discovery_service = discovery_service or ConfigDiscoveryService(
52
55
  project_root=project_root,
53
56
  global_config_path=global_config_path,
57
+ enable_claude_code=enable_claude_code,
54
58
  )
55
59
  self._customizations: list[Customization] = []
56
60
  self._level_filter: ConfigLevel | None = None
@@ -60,7 +64,9 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
60
64
  self._main_pane: MainPane | None = None
61
65
  self._filter_input: FilterInput | None = None
62
66
  self._app_footer: AppFooter | None = None
67
+ self._level_selector: LevelSelector | None = None
63
68
  self._last_focused_panel: TypePanel | CombinedPanel | None = None
69
+ self._pending_customization: Customization | None = None
64
70
 
65
71
  def compose(self) -> ComposeResult:
66
72
  """Compose the application layout."""
@@ -105,6 +111,9 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
105
111
  self._filter_input = FilterInput(id="filter-input")
106
112
  yield self._filter_input
107
113
 
114
+ self._level_selector = LevelSelector(id="level-selector")
115
+ yield self._level_selector
116
+
108
117
  self._app_footer = AppFooter(id="app-footer")
109
118
  yield self._app_footer
110
119
 
@@ -153,7 +162,12 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
153
162
  """Get customizations filtered by current level and search query."""
154
163
  result = self._customizations
155
164
  if self._level_filter:
156
- result = [c for c in result if c.level == self._level_filter]
165
+ # When filtering by level, only show OpenCode items (exclude Claude Code)
166
+ result = [
167
+ c
168
+ for c in result
169
+ if c.level == self._level_filter and c.source == ConfigSource.OPENCODE
170
+ ]
157
171
  if self._search_query:
158
172
  query = self._search_query.lower()
159
173
  result = [c for c in result if query in c.name.lower()]
@@ -296,14 +310,130 @@ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
296
310
  except Exception as e:
297
311
  self.notify(f"Error opening editor: {e}", severity="error")
298
312
 
313
+ # Copy actions
314
+
315
+ def action_copy_customization(self) -> None:
316
+ """Copy selected customization to another level."""
317
+ panel = self._get_focused_panel()
318
+ customization = None
319
+
320
+ if panel:
321
+ customization = panel.selected_customization
322
+
323
+ if not customization:
324
+ self.notify("No customization selected", severity="warning")
325
+ return
326
+
327
+ # Only allow copying commands, agents, and skills
328
+ copyable_types = (
329
+ CustomizationType.COMMAND,
330
+ CustomizationType.AGENT,
331
+ CustomizationType.SKILL,
332
+ )
333
+ if customization.type not in copyable_types:
334
+ self.notify(
335
+ f"Cannot copy {customization.type_label} customizations",
336
+ severity="warning",
337
+ )
338
+ return
339
+
340
+ available = customization.get_copy_targets()
341
+ if not available:
342
+ self.notify("No available target levels", severity="warning")
343
+ return
344
+
345
+ self._pending_customization = customization
346
+ self._last_focused_panel = panel
347
+ if self._level_selector:
348
+ self._level_selector.show(available, customization.name)
349
+
350
+ def action_copy_path_to_clipboard(self) -> None:
351
+ """Copy path of selected customization to clipboard."""
352
+ panel = self._get_focused_panel()
353
+ customization = None
354
+
355
+ if panel:
356
+ customization = panel.selected_customization
357
+
358
+ if not customization:
359
+ self.notify("No customization selected", severity="warning")
360
+ return
361
+
362
+ file_path = customization.path
363
+ if customization.type == CustomizationType.SKILL:
364
+ file_path = customization.path.parent
365
+
366
+ try:
367
+ import pyperclip
368
+
369
+ pyperclip.copy(str(file_path))
370
+ self.notify(f"Copied: {file_path}", severity="information")
371
+ except ImportError:
372
+ self.notify(
373
+ "pyperclip not installed. Run: pip install pyperclip",
374
+ severity="error",
375
+ )
376
+ except Exception as e:
377
+ self.notify(f"Failed to copy to clipboard: {e}", severity="error")
378
+
379
+ # Level selector message handlers
380
+
381
+ def on_level_selector_level_selected(
382
+ self, message: LevelSelector.LevelSelected
383
+ ) -> None:
384
+ """Handle level selection from the level selector."""
385
+ if self._pending_customization:
386
+ self._handle_copy(self._pending_customization, message.level)
387
+ self._pending_customization = None
388
+ self._restore_focus_after_selector()
389
+
390
+ def on_level_selector_selection_cancelled(
391
+ self,
392
+ message: LevelSelector.SelectionCancelled, # noqa: ARG002
393
+ ) -> None:
394
+ """Handle level selector cancellation."""
395
+ self._pending_customization = None
396
+ self._restore_focus_after_selector()
397
+
398
+ def _handle_copy(
399
+ self, customization: Customization, target_level: ConfigLevel
400
+ ) -> None:
401
+ """Handle copy operation."""
402
+ from lazyopencode.services.writer import CustomizationWriter
403
+
404
+ writer = CustomizationWriter(
405
+ global_config_path=self._discovery_service.global_config_path,
406
+ project_config_path=self._discovery_service.project_config_path,
407
+ )
408
+
409
+ success, msg = writer.copy_customization(customization, target_level)
410
+
411
+ if success:
412
+ self.notify(msg, severity="information")
413
+ self.action_refresh()
414
+ else:
415
+ self.notify(msg, severity="error")
416
+
417
+ def _restore_focus_after_selector(self) -> None:
418
+ """Restore focus to the previously focused panel."""
419
+ if self._last_focused_panel:
420
+ self._last_focused_panel.focus()
421
+ elif self._panels:
422
+ self._panels[0].focus()
423
+
299
424
 
300
425
  def create_app(
301
426
  project_root: Path | None = None,
302
427
  global_config_path: Path | None = None,
428
+ enable_claude_code: bool = False,
303
429
  ) -> LazyOpenCode:
304
430
  """Create application with all dependencies wired."""
305
431
  discovery_service = ConfigDiscoveryService(
306
432
  project_root=project_root,
307
433
  global_config_path=global_config_path,
434
+ enable_claude_code=enable_claude_code,
435
+ )
436
+ return LazyOpenCode(
437
+ discovery_service=discovery_service,
438
+ enable_claude_code=enable_claude_code,
308
439
  )
309
- return LazyOpenCode(discovery_service=discovery_service)
@@ -7,6 +7,8 @@ APP_BINDINGS: list[BindingType] = [
7
7
  Binding("?", "toggle_help", "Help"),
8
8
  Binding("r", "refresh", "Refresh"),
9
9
  Binding("e", "open_in_editor", "Edit"),
10
+ Binding("c", "copy_customization", "Copy"),
11
+ Binding("C", "copy_path_to_clipboard", "Copy Path", key_display="shift+c"),
10
12
  Binding("ctrl+u", "open_user_config", "User Config"),
11
13
  Binding("tab", "focus_next_panel", "Next Panel", show=False),
12
14
  Binding("shift+tab", "focus_previous_panel", "Prev Panel", show=False),
@@ -50,6 +50,8 @@ class HelpMixin:
50
50
  Combined: switch tabs
51
51
 
52
52
  [bold]Actions[/]
53
+ c Copy to level (modal)
54
+ C (shift+c) Copy path to clipboard
53
55
  e Open in $EDITOR
54
56
  ctrl+u Open User Config
55
57
  r Refresh from disk
@@ -43,6 +43,13 @@ class ConfigLevel(Enum):
43
43
  return "G" if self == ConfigLevel.GLOBAL else "P"
44
44
 
45
45
 
46
+ class ConfigSource(Enum):
47
+ """Source of customization configuration."""
48
+
49
+ OPENCODE = "opencode" # Native OpenCode customizations
50
+ CLAUDE_CODE = "claude" # Imported from Claude Code paths
51
+
52
+
46
53
  class CustomizationType(Enum):
47
54
  """Type of OpenCode customization."""
48
55
 
@@ -93,6 +100,8 @@ class Customization:
93
100
  content: str | None = None
94
101
  metadata: dict[str, Any] = field(default_factory=dict)
95
102
  error: str | None = None
103
+ source: ConfigSource = ConfigSource.OPENCODE
104
+ source_level: str | None = None # "user", "project", "plugin" for Claude Code
96
105
 
97
106
  @property
98
107
  def type_label(self) -> str:
@@ -105,16 +114,38 @@ class Customization:
105
114
  return self.level.label
106
115
 
107
116
  @property
108
- def level_icon(self) -> str:
109
- """Level icon for compact display."""
110
- return self.level.icon
117
+ def level_icon(self) -> str | None:
118
+ """Level icon for compact display. Returns None for OpenCode items."""
119
+ if self.source == ConfigSource.CLAUDE_CODE:
120
+ return "👾"
121
+ return None
111
122
 
112
123
  @property
113
124
  def display_name(self) -> str:
114
125
  """Formatted name for display with level indicator."""
115
- return f"[{self.level_icon}] {self.name}"
126
+ icon = self.level_icon
127
+ parts = []
128
+ if icon:
129
+ parts.append(f"[{icon}]")
130
+ parts.append(self.name)
131
+
132
+ # Add dimmed plugin name for plugin-sourced items
133
+ if self.source_level and self.source_level.startswith("plugin:"):
134
+ plugin_name = self.source_level.replace("plugin:", "")
135
+ parts.append(f"[dim]{plugin_name}[/dim]")
136
+
137
+ return " ".join(parts)
116
138
 
117
139
  @property
118
140
  def has_error(self) -> bool:
119
141
  """Check if this customization has a parse error."""
120
142
  return self.error is not None
143
+
144
+ def get_copy_targets(self) -> list[ConfigLevel]:
145
+ """Get valid copy target levels for this item."""
146
+ if self.source == ConfigSource.CLAUDE_CODE:
147
+ return [ConfigLevel.GLOBAL, ConfigLevel.PROJECT]
148
+ elif self.level == ConfigLevel.GLOBAL:
149
+ return [ConfigLevel.PROJECT]
150
+ else:
151
+ return [ConfigLevel.GLOBAL]
@@ -0,0 +1,9 @@
1
+ """Claude Code integration layer for LazyOpenCode.
2
+
3
+ This module provides discovery of Claude Code customizations from standard
4
+ Claude Code paths (~/.claude/, ./.claude/, plugins).
5
+ """
6
+
7
+ from lazyopencode.services.claude_code.discovery import ClaudeCodeDiscoveryService
8
+
9
+ __all__ = ["ClaudeCodeDiscoveryService"]
@@ -0,0 +1,158 @@
1
+ """Service for discovering Claude Code customizations."""
2
+
3
+ from pathlib import Path
4
+
5
+ from lazyopencode.models.customization import (
6
+ ConfigLevel,
7
+ Customization,
8
+ )
9
+ from lazyopencode.services.claude_code.parsers.agent import AgentParser
10
+ from lazyopencode.services.claude_code.parsers.command import CommandParser
11
+ from lazyopencode.services.claude_code.parsers.skill import SkillParser
12
+ from lazyopencode.services.claude_code.plugin_loader import PluginLoader
13
+ from lazyopencode.services.gitignore_filter import GitignoreFilter
14
+
15
+
16
+ class ClaudeCodeDiscoveryService:
17
+ """Discovers Claude Code customizations from standard Claude Code paths.
18
+
19
+ This is a separate layer responsible for Claude Code integration.
20
+
21
+ Discovery paths:
22
+ - User: ~/.claude/
23
+ - Project: ./.claude/
24
+ - Plugins: ~/.claude/plugins/ (via plugin registry)
25
+ """
26
+
27
+ def __init__(self, project_root: Path) -> None:
28
+ self.project_root = project_root
29
+ self.user_path = Path.home() / ".claude"
30
+ self.plugins_path = self.user_path / "plugins"
31
+ self._gitignore_filter = GitignoreFilter(project_root=project_root)
32
+ self._plugin_loader = PluginLoader(
33
+ user_config_path=self.user_path,
34
+ project_root=project_root,
35
+ )
36
+
37
+ def discover_all(self) -> list[Customization]:
38
+ """Discover all Claude Code customizations."""
39
+ customizations: list[Customization] = []
40
+
41
+ # User-level
42
+ customizations.extend(self._discover_from_path(self.user_path, "user"))
43
+
44
+ # Project-level
45
+ project_claude = self.project_root / ".claude"
46
+ customizations.extend(self._discover_from_path(project_claude, "project"))
47
+
48
+ # Plugin-level (using plugin registry)
49
+ customizations.extend(self._discover_from_plugins())
50
+
51
+ return customizations
52
+
53
+ def _discover_from_path(self, base: Path, source_level: str) -> list[Customization]:
54
+ """Discover commands, agents, skills from a Claude Code path."""
55
+ if not base.exists():
56
+ return []
57
+
58
+ customizations: list[Customization] = []
59
+ level = ConfigLevel.GLOBAL if source_level == "user" else ConfigLevel.PROJECT
60
+
61
+ # Commands: base/commands/**/*.md
62
+ commands_path = base / "commands"
63
+ customizations.extend(
64
+ self._discover_commands(commands_path, level, source_level)
65
+ )
66
+
67
+ # Agents: base/agents/*.md
68
+ agents_path = base / "agents"
69
+ customizations.extend(self._discover_agents(agents_path, level, source_level))
70
+
71
+ # Skills: base/skills/*/SKILL.md
72
+ skills_path = base / "skills"
73
+ customizations.extend(self._discover_skills(skills_path, level, source_level))
74
+
75
+ return customizations
76
+
77
+ def _discover_commands(
78
+ self, commands_path: Path, level: ConfigLevel, source_level: str
79
+ ) -> list[Customization]:
80
+ """Discover command customizations from commands directory."""
81
+ if not commands_path.exists():
82
+ return []
83
+
84
+ customizations: list[Customization] = []
85
+ parser = CommandParser(commands_path)
86
+
87
+ # Use rglob for nested commands (commands/**/*.md)
88
+ for md_file in commands_path.rglob("*.md"):
89
+ if parser.can_parse(md_file):
90
+ customizations.append(parser.parse(md_file, level, source_level))
91
+
92
+ return customizations
93
+
94
+ def _discover_agents(
95
+ self, agents_path: Path, level: ConfigLevel, source_level: str
96
+ ) -> list[Customization]:
97
+ """Discover agent customizations from agents directory."""
98
+ if not agents_path.exists():
99
+ return []
100
+
101
+ customizations: list[Customization] = []
102
+ parser = AgentParser(agents_path)
103
+
104
+ for md_file in agents_path.glob("*.md"):
105
+ if parser.can_parse(md_file):
106
+ customizations.append(parser.parse(md_file, level, source_level))
107
+
108
+ return customizations
109
+
110
+ def _discover_skills(
111
+ self, skills_path: Path, level: ConfigLevel, source_level: str
112
+ ) -> list[Customization]:
113
+ """Discover skill customizations from skills directory."""
114
+ if not skills_path.exists():
115
+ return []
116
+
117
+ customizations: list[Customization] = []
118
+ parser = SkillParser(skills_path, gitignore_filter=self._gitignore_filter)
119
+
120
+ for skill_dir in skills_path.iterdir():
121
+ if skill_dir.is_dir():
122
+ skill_file = skill_dir / "SKILL.md"
123
+ if skill_file.exists() and parser.can_parse(skill_file):
124
+ customizations.append(parser.parse(skill_file, level, source_level))
125
+
126
+ return customizations
127
+
128
+ def _discover_from_plugins(self) -> list[Customization]:
129
+ """Discover customizations from installed Claude Code plugins."""
130
+ customizations: list[Customization] = []
131
+
132
+ for plugin_info in self._plugin_loader.get_all_plugins():
133
+ install_path = plugin_info.install_path
134
+ source_level = f"plugin:{plugin_info.short_name}"
135
+
136
+ # Commands
137
+ commands_path = install_path / "commands"
138
+ customizations.extend(
139
+ self._discover_commands(commands_path, ConfigLevel.GLOBAL, source_level)
140
+ )
141
+
142
+ # Agents
143
+ agents_path = install_path / "agents"
144
+ customizations.extend(
145
+ self._discover_agents(agents_path, ConfigLevel.GLOBAL, source_level)
146
+ )
147
+
148
+ # Skills
149
+ skills_path = install_path / "skills"
150
+ customizations.extend(
151
+ self._discover_skills(skills_path, ConfigLevel.GLOBAL, source_level)
152
+ )
153
+
154
+ return customizations
155
+
156
+ def refresh(self) -> None:
157
+ """Clear cached plugin registry to force reload."""
158
+ self._plugin_loader.refresh()
@@ -0,0 +1,7 @@
1
+ """Parsers for Claude Code customizations."""
2
+
3
+ from lazyopencode.services.claude_code.parsers.agent import AgentParser
4
+ from lazyopencode.services.claude_code.parsers.command import CommandParser
5
+ from lazyopencode.services.claude_code.parsers.skill import SkillParser
6
+
7
+ __all__ = ["CommandParser", "AgentParser", "SkillParser"]
@@ -0,0 +1,58 @@
1
+ """Parser for Claude Code subagent customizations."""
2
+
3
+ from pathlib import Path
4
+
5
+ from lazyopencode.models.customization import (
6
+ ConfigLevel,
7
+ ConfigSource,
8
+ Customization,
9
+ CustomizationType,
10
+ )
11
+ from lazyopencode.services.parsers import parse_frontmatter
12
+
13
+
14
+ class AgentParser:
15
+ """Parser for subagent markdown files.
16
+
17
+ File pattern: agents/*.md
18
+ """
19
+
20
+ def __init__(self, agents_dir: Path) -> None:
21
+ """Initialize with the agents directory path."""
22
+ self.agents_dir = agents_dir
23
+
24
+ def can_parse(self, path: Path) -> bool:
25
+ """Check if path is a markdown file in agents directory."""
26
+ return path.suffix == ".md" and path.parent == self.agents_dir
27
+
28
+ def parse(self, path: Path, level: ConfigLevel, source_level: str) -> Customization:
29
+ """Parse a subagent markdown file."""
30
+ try:
31
+ content = path.read_text(encoding="utf-8")
32
+ except OSError as e:
33
+ return Customization(
34
+ name=path.stem,
35
+ type=CustomizationType.AGENT,
36
+ level=level,
37
+ path=path,
38
+ error=f"Failed to read file: {e}",
39
+ source=ConfigSource.CLAUDE_CODE,
40
+ source_level=source_level,
41
+ )
42
+
43
+ frontmatter, _ = parse_frontmatter(content)
44
+
45
+ name = frontmatter.get("name", path.stem)
46
+ description = frontmatter.get("description")
47
+
48
+ return Customization(
49
+ name=name,
50
+ type=CustomizationType.AGENT,
51
+ level=level,
52
+ path=path,
53
+ description=description,
54
+ content=content,
55
+ metadata=frontmatter,
56
+ source=ConfigSource.CLAUDE_CODE,
57
+ source_level=source_level,
58
+ )
@@ -0,0 +1,75 @@
1
+ """Parser for Claude Code slash command customizations."""
2
+
3
+ from pathlib import Path
4
+
5
+ from lazyopencode.models.customization import (
6
+ ConfigLevel,
7
+ ConfigSource,
8
+ Customization,
9
+ CustomizationType,
10
+ )
11
+ from lazyopencode.services.parsers import parse_frontmatter
12
+
13
+
14
+ class CommandParser:
15
+ """Parser for slash command markdown files.
16
+
17
+ File pattern: commands/**/*.md
18
+ """
19
+
20
+ def __init__(self, commands_dir: Path) -> None:
21
+ """Initialize with the commands directory path."""
22
+ self.commands_dir = commands_dir
23
+
24
+ def can_parse(self, path: Path) -> bool:
25
+ """Check if path is a markdown file in commands directory."""
26
+ return path.suffix == ".md" and self.commands_dir in path.parents
27
+
28
+ def parse(self, path: Path, level: ConfigLevel, source_level: str) -> Customization:
29
+ """Parse a slash command markdown file."""
30
+ try:
31
+ content = path.read_text(encoding="utf-8")
32
+ except OSError as e:
33
+ return Customization(
34
+ name=self._derive_name(path),
35
+ type=CustomizationType.COMMAND,
36
+ level=level,
37
+ path=path,
38
+ error=f"Failed to read file: {e}",
39
+ source=ConfigSource.CLAUDE_CODE,
40
+ source_level=source_level,
41
+ )
42
+
43
+ frontmatter, body = parse_frontmatter(content)
44
+
45
+ description = frontmatter.get("description")
46
+ if not description and body.strip():
47
+ first_line = body.strip().split("\n")[0]
48
+ if not first_line.startswith("#"):
49
+ description = first_line[:100]
50
+
51
+ return Customization(
52
+ name=self._derive_name(path),
53
+ type=CustomizationType.COMMAND,
54
+ level=level,
55
+ path=path,
56
+ description=description,
57
+ content=content,
58
+ metadata=frontmatter,
59
+ source=ConfigSource.CLAUDE_CODE,
60
+ source_level=source_level,
61
+ )
62
+
63
+ def _derive_name(self, path: Path) -> str:
64
+ """Derive command name from file path.
65
+
66
+ For nested paths: dir/file.md -> dir:file
67
+ For simple paths: file.md -> file
68
+ """
69
+ try:
70
+ relative = path.relative_to(self.commands_dir)
71
+ parts = list(relative.parts)
72
+ parts[-1] = parts[-1].removesuffix(".md")
73
+ return ":".join(parts)
74
+ except ValueError:
75
+ return path.stem