com.elestrago.unity.package-tools 2.0.11 → 2.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 (29) hide show
  1. package/CAHNGELOG.md +17 -0
  2. package/Documentation~/api.md +502 -0
  3. package/Documentation~/manual.md +140 -0
  4. package/Documentation~/samples.md +73 -0
  5. package/Editor/Drawers/CopyEntryPropertyDrawer.cs +95 -0
  6. package/Editor/Drawers/CopyEntryPropertyDrawer.cs.meta +2 -0
  7. package/Editor/EditorConstants.cs +13 -0
  8. package/Editor/Inspectors/PackageManifestConfigInspector.cs +31 -0
  9. package/Editor/PackageManifestConfig.cs +20 -0
  10. package/Editor/Tools/FileTools.cs +106 -0
  11. package/Samples~/ClaudeSkills/Editor/ClaudeSkillsPostImport.cs +213 -0
  12. package/Samples~/ClaudeSkills/Editor/Playdarium.PackageTool.Samples.ClaudeSkills.Editor.asmdef +16 -0
  13. package/Samples~/ClaudeSkills/unity-package-docs/SKILL.md +309 -0
  14. package/Samples~/ClaudeSkills/unity-package-docs/assets/README.md.template +42 -0
  15. package/Samples~/ClaudeSkills/unity-package-docs/assets/api-chunk.md.template +41 -0
  16. package/Samples~/ClaudeSkills/unity-package-docs/assets/api-index.md.template +26 -0
  17. package/Samples~/ClaudeSkills/unity-package-docs/assets/api.md.template +43 -0
  18. package/Samples~/ClaudeSkills/unity-package-docs/assets/manual.md.template +57 -0
  19. package/Samples~/ClaudeSkills/unity-package-docs/assets/samples.md.template +56 -0
  20. package/Samples~/ClaudeSkills/unity-package-docs/scripts/scan_package.py +504 -0
  21. package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/SKILL.md +309 -0
  22. package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/assets/README.md.template +42 -0
  23. package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/assets/api-chunk.md.template +41 -0
  24. package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/assets/api-index.md.template +26 -0
  25. package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/assets/api.md.template +43 -0
  26. package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/assets/manual.md.template +57 -0
  27. package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/assets/samples.md.template +56 -0
  28. package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/scripts/scan_package.py +504 -0
  29. package/package.json +9 -4
@@ -0,0 +1,73 @@
1
+ <!-- generated by unity-package-docs; safe to regenerate -->
2
+
3
+ # Package Tool — Samples
4
+
5
+ How the sample content in this repository uses the package. Each entry maps a sample file to the package types it exercises, so a reader can recreate the same setup against the public API.
6
+
7
+ ## Sample Layout
8
+
9
+ ```
10
+ Assets/Example/Sample
11
+ ├── Prefabs
12
+ │ └── ExamplePrefab.prefab
13
+ └── Scene
14
+ └── SampleScene.unity
15
+ ```
16
+
17
+ On export, `CopySamplesToDirectory` copies this tree to `Release/Samples~/ExampleSample/`, skipping `.meta` files. The shipped sample is consumable from the Package Manager **Samples** tab on the published package.
18
+
19
+ ## Sample → Package Type Mapping
20
+
21
+ ### `Assets/Example/Sample/Prefabs/ExamplePrefab.prefab`
22
+
23
+ Kind: prefab
24
+
25
+ This sample asset references no scripts from the package. It exists as a structural demo (the importing user sees a working Scene/Prefab in the package's Samples tab) rather than as an API exercise.
26
+
27
+ ### `Assets/Example/Sample/Scene/SampleScene.unity`
28
+
29
+ Kind: scene
30
+
31
+ This sample asset references no scripts from the package. It exists as a structural demo (the importing user sees a working Scene/Prefab in the package's Samples tab) rather than as an API exercise.
32
+
33
+ ## Reproducing the Sample
34
+
35
+ The example sample is structural only — there are no MonoBehaviour scripts to instantiate. To reproduce from scratch:
36
+
37
+ 1. Create an empty Unity scene and a primitive prefab; both can live anywhere in `Assets/`.
38
+ 2. Create a `PackageManifestConfig` asset via **Assets > Create > JCMG/PackageTools/PackageManifestConfig** and configure `sourcePath`, `packageDestinationPath`, and a [`Sample`](api.md#packagemanifestconfigsample) entry whose `sourcePath` points at the folder holding the scene and prefab.
39
+ 3. Click **Export Package Source** on the inspector — `FileTools.CreateOrUpdatePackageSource` runs the export pipeline (see `manual.md` → **Export pipeline**) and the sample folder lands under `packageDestinationPath/Samples~/{folderName}`.
40
+
41
+ Minimal equivalent in C#:
42
+
43
+ ```csharp
44
+ using PackageTool;
45
+ using PackageTool.Tools;
46
+ using UnityEditor;
47
+ using UnityEngine;
48
+
49
+ var config = ScriptableObject.CreateInstance<PackageManifestConfig>();
50
+ config.packageName = "com.example.sample";
51
+ config.displayName = "Example Sample Package";
52
+ config.packageVersion = "1.0.0";
53
+ config.unityVersion = "2021.3";
54
+ config.sourcePath = "Assets/Example/Source";
55
+ config.packageDestinationPath = "Release";
56
+ config.samples = new[]
57
+ {
58
+ new PackageManifestConfig.Sample
59
+ {
60
+ sourcePath = "Assets/Example/Sample",
61
+ displayName = "Example Sample",
62
+ folderName = "ExampleSample",
63
+ description = "This is example for check test samples",
64
+ },
65
+ };
66
+ AssetDatabase.CreateAsset(config, "Assets/Example/PackageManifestConfig.asset");
67
+ FileTools.CreateOrUpdatePackageSource(config);
68
+ ```
69
+
70
+ ## Notes
71
+
72
+ - The `Samples~/` folder name (trailing tilde) is a Unity convention: tilde-suffixed folders are excluded from the AssetDatabase import, so packaged samples don't pollute the consuming project until the user clicks **Import** in the Package Manager.
73
+ - For samples that stage content from outside `Assets/` — e.g. a Claude skill folder, vendored data, or generated output — use a [`CopyEntry`](api.md#packagemanifestconfigcopyentry) with `destinationPath` pointing at the same `sourcePath` your `Sample` declares. The `CopyEntry` step runs first; `CopySamplesToDirectory` then ships the staged content into the package.
@@ -0,0 +1,95 @@
1
+ using PackageTool.Tools;
2
+ using UnityEditor;
3
+ using UnityEngine;
4
+
5
+ namespace PackageTool.Drawers
6
+ {
7
+ [CustomPropertyDrawer(typeof(PackageManifestConfig.CopyEntry))]
8
+ internal sealed class CopyEntryPropertyDrawer : PropertyDrawer
9
+ {
10
+ private const string SOURCE_PATH_PROPERTY_NAME = "sourcePath";
11
+ private const string DESTINATION_PATH_PROPERTY_NAME = "destinationPath";
12
+
13
+ public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
14
+ {
15
+ var sourceRect = new Rect(position)
16
+ {
17
+ height = EditorConstants.FOLDER_PATH_PICKER_HEIGHT
18
+ };
19
+
20
+ var destinationRect = new Rect(sourceRect)
21
+ {
22
+ position = new Vector2(position.x, sourceRect.y + sourceRect.height + 2f)
23
+ };
24
+
25
+ DrawSourceRow(sourceRect, property.FindPropertyRelative(SOURCE_PATH_PROPERTY_NAME));
26
+ DrawDestinationRow(destinationRect, property.FindPropertyRelative(DESTINATION_PATH_PROPERTY_NAME));
27
+ }
28
+
29
+ public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
30
+ => EditorConstants.FOLDER_PATH_PICKER_HEIGHT * 2f + 2f;
31
+
32
+ private static void DrawSourceRow(Rect rowRect, SerializedProperty pathProperty)
33
+ {
34
+ var fieldRect = new Rect(rowRect)
35
+ {
36
+ width = rowRect.width - EditorConstants.FOLDER_PATH_PICKER_HEIGHT * 2f
37
+ - EditorConstants.FOLDER_PATH_PICKER_BUFFER
38
+ };
39
+
40
+ EditorGUI.PropertyField(fieldRect, pathProperty, new GUIContent(EditorConstants.COPY_ENTRY_SOURCE_LABEL));
41
+
42
+ var filePickerRect = new Rect
43
+ {
44
+ position = new Vector2(
45
+ fieldRect.x + fieldRect.width + EditorConstants.FOLDER_PATH_PICKER_BUFFER,
46
+ rowRect.y),
47
+ width = EditorConstants.FOLDER_PATH_PICKER_HEIGHT,
48
+ height = EditorConstants.FOLDER_PATH_PICKER_HEIGHT
49
+ };
50
+
51
+ var folderPickerRect = new Rect
52
+ {
53
+ position = new Vector2(
54
+ filePickerRect.x + filePickerRect.width,
55
+ rowRect.y),
56
+ width = EditorConstants.FOLDER_PATH_PICKER_HEIGHT,
57
+ height = EditorConstants.FOLDER_PATH_PICKER_HEIGHT
58
+ };
59
+
60
+ GUILayoutTools.DrawFilePicker(
61
+ filePickerRect,
62
+ pathProperty,
63
+ EditorConstants.SELECT_SOURCE_PATH_FILE_PICKER_TITLE);
64
+ GUILayoutTools.DrawFolderPicker(
65
+ folderPickerRect,
66
+ pathProperty,
67
+ EditorConstants.SELECT_SOURCE_PATH_PICKER_FOLDER_TITLE);
68
+ }
69
+
70
+ private static void DrawDestinationRow(Rect rowRect, SerializedProperty pathProperty)
71
+ {
72
+ var fieldRect = new Rect(rowRect)
73
+ {
74
+ width = rowRect.width - EditorConstants.FOLDER_PATH_PICKER_HEIGHT
75
+ - EditorConstants.FOLDER_PATH_PICKER_BUFFER
76
+ };
77
+
78
+ EditorGUI.PropertyField(fieldRect, pathProperty, new GUIContent(EditorConstants.COPY_ENTRY_DESTINATION_LABEL));
79
+
80
+ var folderPickerRect = new Rect
81
+ {
82
+ position = new Vector2(
83
+ fieldRect.x + fieldRect.width + EditorConstants.FOLDER_PATH_PICKER_BUFFER,
84
+ rowRect.y),
85
+ width = EditorConstants.FOLDER_PATH_PICKER_HEIGHT,
86
+ height = EditorConstants.FOLDER_PATH_PICKER_HEIGHT
87
+ };
88
+
89
+ GUILayoutTools.DrawFolderPicker(
90
+ folderPickerRect,
91
+ pathProperty,
92
+ EditorConstants.SELECT_COPY_DESTINATION_FOLDER_PICKER_TITLE);
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,2 @@
1
+ fileFormatVersion: 2
2
+ guid: 88c4785ea4243706f9dbebc1dd7a5ae4
@@ -105,8 +105,21 @@ namespace PackageTool
105
105
  public const string PACKAGE_UPDATE_SUCCESS_FORMAT =
106
106
  "[Package Tools] Successfully updated package source for [{0}].";
107
107
 
108
+ public const string AGENT_LOCKED_FILE_MARKER_FORMAT =
109
+ "<!--\n" +
110
+ "AGENT NOTE: This file is auto-generated and locked for direct edits.\n" +
111
+ "Canonical source: ./{0} (at the project root). Edit that file instead.\n" +
112
+ "Regenerated from the canonical source on every package export.\n" +
113
+ "-->\n\n";
114
+
108
115
  public const string SAMPLES_FOLDER_NAME = "Samples~";
109
116
  public const string SAMPLES_HEADER_LABEL = "Samples";
110
117
  public const string SAMPLES_ELEMENT_LABEL_FORMAT = "Sample {0}:";
118
+
119
+ public const string COPY_ENTRIES_HEADER_LABEL = "Copy Entries";
120
+ public const string COPY_ENTRY_ELEMENT_LABEL_FORMAT = "Copy {0}:";
121
+ public const string COPY_ENTRY_SOURCE_LABEL = "Source";
122
+ public const string COPY_ENTRY_DESTINATION_LABEL = "Destination";
123
+ public const string SELECT_COPY_DESTINATION_FOLDER_PICKER_TITLE = "Select Copy Destination Folder";
111
124
  }
112
125
  }
@@ -37,6 +37,7 @@ namespace PackageTool.Inspectors
37
37
  private ReorderableList _keywordReorderableList;
38
38
  private ReorderableList _dependenciesReorderableList;
39
39
  private ReorderableList _samplesReorderableList;
40
+ private ReorderableList _copyEntriesReorderableList;
40
41
 
41
42
  private const string SOURCE_PATH_PROPERTY_NAME = "sourcePath";
42
43
  private const string DOCUMENTATION_PATH_PROPERTY_NAME = "documentationPath";
@@ -61,6 +62,7 @@ namespace PackageTool.Inspectors
61
62
  private const string VERSION_CONSTANTS_NAMESPACE_PROPERTY_NAME = "versionConstantsNamespace";
62
63
  private const string ID_PROPERTY_NAME = "_id";
63
64
  private const string SAMPLES_PROPERTY_NAME = "samples";
65
+ private const string COPY_ENTRIES_PROPERTY_NAME = "copyEntries";
64
66
 
65
67
  private void OnEnable()
66
68
  {
@@ -109,6 +111,16 @@ namespace PackageTool.Inspectors
109
111
  drawElementCallback = DrawSampleElement,
110
112
  elementHeight = EditorGUIUtility.singleLineHeight * 5f
111
113
  };
114
+
115
+ var copyEntriesProp = serializedObject.FindProperty(COPY_ENTRIES_PROPERTY_NAME);
116
+ _copyEntriesReorderableList = new ReorderableList(
117
+ serializedObject,
118
+ copyEntriesProp)
119
+ {
120
+ drawHeaderCallback = DrawCopyEntriesHeader,
121
+ drawElementCallback = DrawCopyEntryElement,
122
+ elementHeight = EditorConstants.FOLDER_PATH_PICKER_HEIGHT * 2f + 2f
123
+ };
112
124
  }
113
125
 
114
126
  public override void OnInspectorGUI()
@@ -203,6 +215,7 @@ namespace PackageTool.Inspectors
203
215
  EditorGUILayout.Space();
204
216
  // _sourcePathsReorderableList.DoLayoutList();
205
217
  _excludePathsReorderableList.DoLayoutList();
218
+ _copyEntriesReorderableList.DoLayoutList();
206
219
 
207
220
  // Package Source Export
208
221
  using (new EditorGUILayout.HorizontalScope())
@@ -400,5 +413,23 @@ namespace PackageTool.Inspectors
400
413
  }
401
414
 
402
415
  #endregion
416
+
417
+ #region Copy Entries ReorderableList
418
+
419
+ private void DrawCopyEntriesHeader(Rect rect)
420
+ {
421
+ EditorGUI.LabelField(rect, EditorConstants.COPY_ENTRIES_HEADER_LABEL, EditorStyles.boldLabel);
422
+ }
423
+
424
+ private void DrawCopyEntryElement(Rect rect, int index, bool isActive, bool isFocused)
425
+ {
426
+ EditorGUI.PropertyField(
427
+ rect,
428
+ serializedObject.FindProperty(COPY_ENTRIES_PROPERTY_NAME).GetArrayElementAtIndex(index),
429
+ new GUIContent(string.Format(EditorConstants.COPY_ENTRY_ELEMENT_LABEL_FORMAT, index))
430
+ );
431
+ }
432
+
433
+ #endregion
403
434
  }
404
435
  }
@@ -82,6 +82,21 @@ namespace PackageTool
82
82
  || string.IsNullOrEmpty(folderName);
83
83
  }
84
84
 
85
+ /// <summary>
86
+ /// Describes a copy operation that mirrors a source file or folder into a destination folder inside the
87
+ /// project. When the source is a folder, the same-named subfolder of the destination is replaced with the
88
+ /// source folder's content. When the source is a file, the same-named file inside the destination folder
89
+ /// is replaced. An empty or non-existent destination path falls back to the project root.
90
+ /// </summary>
91
+ [Serializable]
92
+ public sealed class CopyEntry
93
+ {
94
+ public string sourcePath;
95
+ public string destinationPath;
96
+
97
+ public bool IsEmpty() => string.IsNullOrEmpty(sourcePath);
98
+ }
99
+
85
100
  /// <summary>
86
101
  /// A unique id for this <see cref="PackageManifestConfig"/> instance.
87
102
  /// </summary>
@@ -162,6 +177,11 @@ namespace PackageTool
162
177
 
163
178
  public Sample[] samples;
164
179
 
180
+ /// <summary>
181
+ /// A collection of additional source-to-destination copy operations performed during export.
182
+ /// </summary>
183
+ public CopyEntry[] copyEntries;
184
+
165
185
  /// <summary>
166
186
  /// A path to the where the VersionConstants.cs file should be created/updated
167
187
  /// </summary>
@@ -122,9 +122,11 @@ namespace PackageTool.Tools
122
122
  // If its a file, copy over it and its meta file if it exists.
123
123
  var normalizedSourcePath = Path.GetFullPath(packageManifest.sourcePath);
124
124
 
125
+ CopyEntriesToProject(packageManifest);
125
126
  CopyDocumentationToDirectory(packageManifest);
126
127
  RecursivelyCopyDirectoriesAndFiles(packageManifest, normalizedSourcePath, normalizedDestinationPath);
127
128
  CopySamplesToDirectory(packageManifest);
129
+ StampLocalDocCopiesForAgents(packageManifest);
128
130
 
129
131
  Debug.LogFormat(EditorConstants.PACKAGE_UPDATE_SUCCESS_FORMAT, packageManifest.packageName);
130
132
 
@@ -192,6 +194,110 @@ namespace PackageTool.Tools
192
194
  File.Copy(normalizedSourcePath, destinationPath);
193
195
  }
194
196
 
197
+ /// <summary>
198
+ /// Stages each configured <see cref="PackageManifestConfig.CopyEntry"/> into the project before the
199
+ /// rest of the export runs. A folder source's content is merged directly into
200
+ /// <see cref="PackageManifestConfig.CopyEntry.destinationPath"/> (same-named files are overwritten,
201
+ /// unrelated files in the destination are left alone). A file source overwrites
202
+ /// <c>destination/{filename}</c>. A blank destination resolves to the project root; a non-blank
203
+ /// destination that does not yet exist is created. Missing sources log a warning and are skipped.
204
+ /// </summary>
205
+ public static void CopyEntriesToProject(PackageManifestConfig packageManifest)
206
+ {
207
+ var entries = packageManifest.copyEntries;
208
+ if (entries == null || entries.Length == 0)
209
+ return;
210
+
211
+ foreach (var entry in entries)
212
+ {
213
+ if (entry.IsEmpty())
214
+ continue;
215
+
216
+ var normalizedSourcePath = Path.GetFullPath(entry.sourcePath);
217
+ var destinationDirectory = ResolveDestinationDirectory(entry.destinationPath);
218
+
219
+ if (!Directory.Exists(destinationDirectory))
220
+ Directory.CreateDirectory(destinationDirectory);
221
+
222
+ if (!Directory.Exists(normalizedSourcePath) && !File.Exists(normalizedSourcePath))
223
+ {
224
+ Debug.LogWarningFormat(
225
+ "[Package Tools] Copy entry source [{0}] does not exist, skipping.",
226
+ entry.sourcePath);
227
+ continue;
228
+ }
229
+
230
+ if (Directory.Exists(normalizedSourcePath))
231
+ {
232
+ CopyDirectoryRecursively(normalizedSourcePath, destinationDirectory);
233
+ }
234
+ else if (File.Exists(normalizedSourcePath))
235
+ {
236
+ var sourceName = Path.GetFileName(normalizedSourcePath);
237
+ var targetPath = Path.Combine(destinationDirectory, sourceName);
238
+
239
+ if (File.Exists(targetPath))
240
+ File.Delete(targetPath);
241
+
242
+ File.Copy(normalizedSourcePath, targetPath);
243
+ }
244
+ }
245
+ }
246
+
247
+ private static string ResolveDestinationDirectory(string rawDestinationPath)
248
+ {
249
+ if (string.IsNullOrEmpty(rawDestinationPath))
250
+ return EditorConstants.ProjectPath;
251
+
252
+ return Path.GetFullPath(rawDestinationPath);
253
+ }
254
+
255
+ private static void CopyDirectoryRecursively(string normalizedSourcePath, string normalizedDestinationPath)
256
+ {
257
+ var directoryInfo = new DirectoryInfo(normalizedSourcePath);
258
+
259
+ foreach (var sdi in directoryInfo.GetDirectories(EditorConstants.WILDCARD_FILTER, SearchOption.AllDirectories))
260
+ Directory.CreateDirectory(sdi.FullName.Replace(normalizedSourcePath, normalizedDestinationPath));
261
+
262
+ foreach (var fi in directoryInfo.GetFiles(EditorConstants.WILDCARD_FILTER, SearchOption.AllDirectories))
263
+ {
264
+ var newPath = fi.FullName.Replace(normalizedSourcePath, normalizedDestinationPath);
265
+ File.Copy(fi.FullName, newPath, overwrite: true);
266
+ }
267
+ }
268
+
269
+ /// <summary>
270
+ /// Prepends an HTML-comment AGENT NOTE to the markdown documentation copies under
271
+ /// <c>sourcePath</c> so any tool or agent that opens the local artifact sees it is locked
272
+ /// and is pointed at the canonical project-root file. Runs after the recursive copy to
273
+ /// <c>packageDestinationPath</c>, so the shipped package in <c>Release/</c> stays unstamped
274
+ /// and consumers never see the note. Plain-text companions like <c>LICENSE</c> are skipped —
275
+ /// a comment prefix would visibly modify the legal notice.
276
+ /// </summary>
277
+ private static void StampLocalDocCopiesForAgents(PackageManifestConfig packageManifest)
278
+ {
279
+ var sourceDirectory = Path.GetFullPath(packageManifest.sourcePath);
280
+ StampMarkdownCopy(sourceDirectory, packageManifest.readmePath);
281
+ StampMarkdownCopy(sourceDirectory, packageManifest.changelogPath);
282
+ }
283
+
284
+ private static void StampMarkdownCopy(string sourceDirectory, string canonicalRelativePath)
285
+ {
286
+ if (string.IsNullOrEmpty(canonicalRelativePath))
287
+ return;
288
+ if (!canonicalRelativePath.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
289
+ return;
290
+
291
+ var fileName = Path.GetFileName(canonicalRelativePath);
292
+ var localCopyPath = Path.Combine(sourceDirectory, fileName);
293
+ if (!File.Exists(localCopyPath))
294
+ return;
295
+
296
+ var marker = string.Format(EditorConstants.AGENT_LOCKED_FILE_MARKER_FORMAT, canonicalRelativePath);
297
+ var existing = File.ReadAllText(localCopyPath);
298
+ File.WriteAllText(localCopyPath, marker + existing);
299
+ }
300
+
195
301
  private static void CopySamplesToDirectory(PackageManifestConfig packageManifest)
196
302
  {
197
303
  var samples = packageManifest.samples;
@@ -0,0 +1,213 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using System.IO;
4
+ using UnityEditor;
5
+ using UnityEngine;
6
+
7
+ namespace PackageTool.Samples.ClaudeSkills
8
+ {
9
+ /// <summary>
10
+ /// Runs once after the "Claude Skills" sample is imported into a consumer project. Walks every
11
+ /// skill subfolder next to this script, copies it to <c>&lt;project&gt;/.claude/skills/&lt;name&gt;</c>,
12
+ /// prompting before overwriting an existing skill, and then deletes the imported sample folder so
13
+ /// the operation does not repeat on subsequent domain reloads.
14
+ /// </summary>
15
+ [InitializeOnLoad]
16
+ internal static class ClaudeSkillsPostImport
17
+ {
18
+ private const string LOG_PREFIX = "[Claude Skills]";
19
+ private const string SKILLS_DESTINATION_RELATIVE = ".claude/skills";
20
+ private const string EDITOR_FOLDER_NAME = "Editor";
21
+ private const string SCRIPT_FILE_NAME = "ClaudeSkillsPostImport.cs";
22
+ private const string DIALOG_TITLE = "Claude Skills";
23
+ private const string SESSION_GUARD_KEY_PREFIX = "PackageTool.ClaudeSkillsPostImport.Processed:";
24
+ private const string SESSION_RUNNING_KEY = "PackageTool.ClaudeSkillsPostImport.Running";
25
+
26
+ static ClaudeSkillsPostImport()
27
+ {
28
+ EditorApplication.delayCall += Run;
29
+ }
30
+
31
+ private static void Run()
32
+ {
33
+ // Re-entrancy guard. Multiple copies of this type (e.g. in side-by-side sample imports
34
+ // each shipping their own assembly) would each schedule a delayCall; without this guard
35
+ // they would race on the same sample folders and double-prompt the user.
36
+ if (SessionState.GetBool(SESSION_RUNNING_KEY, false))
37
+ return;
38
+ SessionState.SetBool(SESSION_RUNNING_KEY, true);
39
+
40
+ try
41
+ {
42
+ foreach (var sampleRootAssetPath in FindSampleRootAssetPaths())
43
+ {
44
+ // Only run from an imported sample location; never act on the source folder
45
+ // inside a package author's project.
46
+ if (!sampleRootAssetPath.StartsWith("Assets/Samples/", StringComparison.Ordinal))
47
+ continue;
48
+
49
+ var guardKey = SESSION_GUARD_KEY_PREFIX + sampleRootAssetPath;
50
+ if (SessionState.GetBool(guardKey, false))
51
+ continue;
52
+ SessionState.SetBool(guardKey, true);
53
+
54
+ ProcessSample(sampleRootAssetPath);
55
+ }
56
+ }
57
+ catch (Exception e)
58
+ {
59
+ Debug.LogErrorFormat("{0} Post-import failed: {1}", LOG_PREFIX, e);
60
+ }
61
+ finally
62
+ {
63
+ SessionState.SetBool(SESSION_RUNNING_KEY, false);
64
+ }
65
+ }
66
+
67
+ private static IEnumerable<string> FindSampleRootAssetPaths()
68
+ {
69
+ var suffix = "/" + EDITOR_FOLDER_NAME + "/" + SCRIPT_FILE_NAME;
70
+ var guids = AssetDatabase.FindAssets("ClaudeSkillsPostImport t:MonoScript");
71
+ var seen = new HashSet<string>(StringComparer.Ordinal);
72
+ foreach (var guid in guids)
73
+ {
74
+ var path = AssetDatabase.GUIDToAssetPath(guid);
75
+ if (string.IsNullOrEmpty(path) || !path.EndsWith(suffix, StringComparison.Ordinal))
76
+ continue;
77
+
78
+ var sampleRoot = path.Substring(0, path.Length - suffix.Length);
79
+ if (seen.Add(sampleRoot))
80
+ yield return sampleRoot;
81
+ }
82
+ }
83
+
84
+ private static void ProcessSample(string sampleRootAssetPath)
85
+ {
86
+ var projectRoot = ResolveProjectRoot();
87
+ var skillsRoot = Path.Combine(projectRoot, SKILLS_DESTINATION_RELATIVE);
88
+ var fullSampleRoot = Path.GetFullPath(sampleRootAssetPath);
89
+
90
+ if (!Directory.Exists(fullSampleRoot))
91
+ {
92
+ Debug.LogWarningFormat("{0} Sample folder not found at [{1}].", LOG_PREFIX, fullSampleRoot);
93
+ return;
94
+ }
95
+
96
+ if (!Directory.Exists(skillsRoot))
97
+ Directory.CreateDirectory(skillsRoot);
98
+
99
+ Debug.LogFormat(
100
+ "{0} Installing skills from [{1}] into [{2}].",
101
+ LOG_PREFIX,
102
+ sampleRootAssetPath,
103
+ ToProjectRelative(skillsRoot, projectRoot));
104
+
105
+ var installed = 0;
106
+ var replaced = 0;
107
+ var skipped = 0;
108
+
109
+ foreach (var skillSource in Directory.GetDirectories(fullSampleRoot))
110
+ {
111
+ var skillName = Path.GetFileName(skillSource);
112
+ if (string.Equals(skillName, EDITOR_FOLDER_NAME, StringComparison.Ordinal))
113
+ continue;
114
+
115
+ var skillTarget = Path.Combine(skillsRoot, skillName);
116
+ if (Directory.Exists(skillTarget))
117
+ {
118
+ var replace = EditorUtility.DisplayDialog(
119
+ DIALOG_TITLE,
120
+ string.Format(
121
+ "Skill \"{0}\" already exists at {1}.\n\nReplace it with the imported version?",
122
+ skillName,
123
+ ToProjectRelative(skillTarget, projectRoot)),
124
+ "Replace",
125
+ "Skip");
126
+
127
+ if (!replace)
128
+ {
129
+ Debug.LogFormat("{0} Skipped \"{1}\" (already exists).", LOG_PREFIX, skillName);
130
+ skipped++;
131
+ continue;
132
+ }
133
+
134
+ Directory.Delete(skillTarget, recursive: true);
135
+ replaced++;
136
+ }
137
+ else
138
+ {
139
+ installed++;
140
+ }
141
+
142
+ CopySkillDirectory(skillSource, skillTarget);
143
+
144
+ Debug.LogFormat(
145
+ "{0} Installed \"{1}\" -> {2}.",
146
+ LOG_PREFIX,
147
+ skillName,
148
+ ToProjectRelative(skillTarget, projectRoot));
149
+ }
150
+
151
+ Debug.LogFormat(
152
+ "{0} Done. Installed: {1}, Replaced: {2}, Skipped: {3}.",
153
+ LOG_PREFIX, installed, replaced, skipped);
154
+
155
+ AssetDatabase.DeleteAsset(sampleRootAssetPath);
156
+ AssetDatabase.Refresh();
157
+ }
158
+
159
+ private static void CopySkillDirectory(string source, string destination)
160
+ {
161
+ Directory.CreateDirectory(destination);
162
+
163
+ foreach (var dir in Directory.GetDirectories(source, "*", SearchOption.AllDirectories))
164
+ {
165
+ var relative = MakeRelative(source, dir);
166
+ Directory.CreateDirectory(Path.Combine(destination, relative));
167
+ }
168
+
169
+ foreach (var file in Directory.GetFiles(source, "*", SearchOption.AllDirectories))
170
+ {
171
+ // Skip Unity meta files; the destination lives outside Assets/ and doesn't need them.
172
+ if (file.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))
173
+ continue;
174
+
175
+ var relative = MakeRelative(source, file);
176
+ var targetPath = Path.Combine(destination, relative);
177
+ var targetDir = Path.GetDirectoryName(targetPath);
178
+ if (!string.IsNullOrEmpty(targetDir))
179
+ Directory.CreateDirectory(targetDir);
180
+
181
+ File.Copy(file, targetPath, overwrite: true);
182
+ }
183
+ }
184
+
185
+ private static string MakeRelative(string root, string fullPath)
186
+ {
187
+ var rel = fullPath.Substring(root.Length);
188
+ return rel.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
189
+ }
190
+
191
+ private static string ResolveProjectRoot()
192
+ {
193
+ const string assetsSuffix = "/Assets";
194
+ var dataPath = Application.dataPath;
195
+ if (dataPath.EndsWith(assetsSuffix, StringComparison.Ordinal))
196
+ return dataPath.Substring(0, dataPath.Length - assetsSuffix.Length);
197
+
198
+ var parent = Directory.GetParent(dataPath);
199
+ return parent != null ? parent.FullName : dataPath;
200
+ }
201
+
202
+ private static string ToProjectRelative(string fullPath, string projectRoot)
203
+ {
204
+ var normalized = fullPath.Replace('\\', '/');
205
+ var rootNormalized = projectRoot.Replace('\\', '/');
206
+ if (!normalized.StartsWith(rootNormalized, StringComparison.Ordinal))
207
+ return normalized;
208
+
209
+ var rel = normalized.Substring(rootNormalized.Length).TrimStart('/');
210
+ return string.IsNullOrEmpty(rel) ? "." : rel;
211
+ }
212
+ }
213
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "Playdarium.PackageTool.Samples.ClaudeSkills.Editor",
3
+ "rootNamespace": "",
4
+ "references": [],
5
+ "includePlatforms": [
6
+ "Editor"
7
+ ],
8
+ "excludePlatforms": [],
9
+ "allowUnsafeCode": false,
10
+ "overrideReferences": false,
11
+ "precompiledReferences": [],
12
+ "autoReferenced": true,
13
+ "defineConstraints": [],
14
+ "versionDefines": [],
15
+ "noEngineReferences": false
16
+ }